Word add-in: client-executed tool loop for tracked edits
From the PR description
What this is
A redesign of how the Word add-in applies document edits. Today the model embeds <original>/<replacement>/<reason> blocks in its streamed answer text; the task pane scrapes them with a streaming parser and applies them fire-and-forget - if an edit fails to apply (text not found, ambiguous match), the model never finds out and happily summarizes changes that didn't happen.
This PR makes document edits first-class tools in the existing backend tool loop, executed in the client - because in the Word surface, the document lives in the client (Office.js is the only thing that can touch it), the inverse of the web app where documents live in the backend:
- The model calls
apply_word_edits/read_active_documentlike any other tool. - The backend doesn't execute it - it forwards the call down the chat's SSE stream as a
client_tool_callframe. - The task pane executes it via Office.js (tracked changes / live body read) and POSTs the outcome to the new
POST /word-chat/tool-resultendpoint. - An in-memory pending-call bridge correlates the POST back to the awaiting tool loop, and the model continues with a real per-edit result - including
not-found/ambiguousfailures with retry hints, so it can re-read, fix the passage, and retry only the failed edits.
Edits are persisted as structured word_edits assistant events (statuses included) instead of being re-parsed out of answer text; edit cards render from those events with the same review/accept/reject controls. The legacy tag protocol remains fully supported behind a capability flag (client_tools: true in the chat POST): old panes keep the old prompt and are never handed tool calls they can't answer.
Second review round (commits 7-8)
Three parallel reviewers (backend concurrency, client/Office.js, model-facing protocol) re-audited the branch including the first round's fixes. Highlights of what they caught and this round fixes:
- The "unknown" status was forgeable - it keyed on a posted
{timeout:true}field. Timeout/cancel are now module-private sentinels matched by object identity; a wire payload can imitate the shape but never the reference. - A wedged pane could burn ~16 min of held SSE + paid model calls. The adapter now stops forwarding after 2 consecutive timeouts, budgets 12 client calls per turn (erroring with "summarize now" before the provider loop truncates silently), caps live reads at 3/turn, and collapses byte-identical re-reads to
{unchanged:true}. - The 60s flat timeout was shorter than a legitimate 50-edit batch (Word Online pays 4-7
context.sync()per edit). Apply deadlines now scale: 30s + 3s/edit, max 180s. /word-chat/tool-resultgets its own rate-limit lane and a 2mb body parser (was: shared 300/15min general budget per office NAT IP + the global 50mb ceiling; posted arrays are also sliced/index-bounded before processing).applied-untracked→applied-unmanaged: Word applies these as tracked changes; the old name plus the prompt's "only claimapplied" rule made the model report real changes as failures. Now counted as success, hinted, prompt updated.- Compact result JSON: counts + one row per non-applied edit + one hint per failure kind (was: identical hint duplicated per row, noise rows for successes); Word's machine reason travels as
skip_reason, not the request'sreasonkey. - Restore kept the truth: a reloaded ambiguous/failed edit keeps its explanation instead of collapsing to a generic historical card (round 1's probe-all fallback discarded persisted statuses).
- Prose streams again in tool mode: the summary-hold logic (needed only for in-prose legacy edits) was hiding already-rendered preamble during applies and batching the closing summary to
[DONE]. - Turn-boundary integrity: terminal saves await in-flight tool executions (a backend timeout could otherwise persist statusless rows later replayed as applied); cancelled local-mode tool-only turns now save their
word_editsrecord; the legacy tag-scraper disarms once aclient_tool_callframe arrives (stray<original>in prose no longer double-applies) while staying armed for old backends; the dead "(invalid edit)" placeholder branch - whose only reachable effect was a permanently frozen "applying" card - is gone; anchors persist in one batched settings save instead of 50. - Prompt/schema sync is now pinned by tests (status vocabulary, the read-rule exemption, legacy-vs-tools variants), plus new e2e specs: ordinal accumulation across sequential calls, exactly-one POST on 404, 500-then-204 retry, ambiguous-edit restore.
Gates after this round (re-run 2026-08-24 on the rebased tip): backend 782 tests green; add-in 330 Playwright e2e green on Chromium and WebKit; both typechecks clean.
Base-case replication (main)
- Boot the local stack: repo-root
docker compose up -d db auth rest gateway mailpit storage(gateway on :54721 via the root.env), backendnpm run devon :3001 (its.envmust pointSUPABASE_URLathttp://127.0.0.1:54721- a stale :54321 value makes every authed call fail 401 "Invalid or expired token"), and the add-in dev server (SUPABASE_PROXY_TARGET=http://127.0.0.1:54721 API_PROXY_TARGET=http://localhost:3001 npm run dev:server, HTTPS :3200); sideloadword-addin/manifest.xmlinto Word online and log in. - Create a document containing the same sentence twice, e.g. paste
The party shall provide notice.on two separate lines. - In the pane, ask: "Change 'shall provide notice' to 'must provide written notice'."
- Observe: the answer streams
<original>/<replacement>markers (hidden into an edit card); the card lands in skipped/ambiguous state because the passage matches twice - but the model's prose summary still claims the change was made. No retry happens; the document is untouched. In DevTools → Network, the/word-chatstream contains onlycontent_deltaframes - there is no channel by which the failure could reach the model.
PR replication (this branch)
- Same setup, same two-line document, same request.
- Observe in DevTools → Network on the
/word-chatstream: aclient_tool_callframe forapply_word_edits, followed by the pane'sPOST /word-chat/tool-result(204) carryingstatus: "ambiguous", matches: 2. - The model receives that result mid-response and retries with an extended, unique passage (or asks which occurrence) - the second
apply_word_editscall succeeds. In Review mode (composer pill) the tool returnsproposedand the card offers Apply/View - the document changes only when a human clicks Apply; in Direct mode (pill shows "Edit") the tracked change lands immediately and the card is pending accept/reject. - Also verify freshness: ask "now delete the sentence you just edited" in the same turn-family - the model calls
read_active_document(a live re-read, visible as anotherclient_tool_callframe) instead of trusting the stale request-time snapshot. - History: reload the pane, reopen the chat - edit cards restore from the persisted
word_editsevents with their apply statuses, and applied edits' tracked changes are re-anchored for view/accept/reject.
Automated: cd backend && npx vitest run (782 tests, includes new bridge/loop/route suites), cd word-addin && npm run typecheck.
Tradeoffs & design decisions
- In-memory bridge → single API instance. The
tool-resultPOST must reach the process holding the SSE socket. This matches the existing single-instance streaming assumptions; a multi-instance deployment would need a Redis pub/sub bridge (natural follow-up on top of the BullMQ work in #294). Flagged, not hidden: the bridge module documents it. - Capability flag instead of a breaking cutover. The pane advertises
client_tools: true; without it the backend keeps the legacy tag-protocol prompt. Both code paths therefore coexist (redline parsing is also still needed to render pre-migration history). Cost: two prompt variants to maintain until the legacy path is retired. - The snapshot read stays.
read_documentover the request-timedocument_contextsnapshot is retained (instant, and citation verification depends on it);read_active_documentis added for post-edit freshness rather than routing all reads through the client. Cost: two read tools in the prompt, mitigated by explicit instructions on when to use which. - Scaled tool timeouts. Apply deadlines scale 30s + 3s/edit (max 180s; reads 60s). If the pane never answers (closed pane, crashed Office.js), the model is told the outcome is
unknownand the stream continues. Race: a very slow apply that completes after timeout leaves the document changed while the model believes the outcome unknown - the prompt's verify-before-retry hint prevents double-apply. Batches are capped at 50 edits and applied in oneWord.run, so this window is small in practice. - Idle SSE while awaiting the client. While the loop waits for the tool result no data frames flow, so the stream emits
: keep-aliveSSE comments every 15s during tool waits (ignored by the pane's reader, enough to keep intermediaries from idling the connection out). - Transcript continuity is client-serialized. With edits no longer inline in answer text, later turns would lose what changed. The pane appends a compact "[Tracked changes applied...]" summary to prior assistant messages when building the request - one mechanism covering both cloud and local storage, chosen over extending the backend's
enrichWithPriorEvents(which only covers the last turn, cloud only). - Edit-card streaming granularity changes. Cards now appear when the tool call arrives (whole batch at once) instead of materializing tag-by-tag mid-prose. Batch apply is one
Word.runfor the whole call, vs. one per edit on the legacy path - fewer Office.js round trips. - Disjoint key spaces. Tool edits key card state at
TOOL_EDIT_INDEX_BASE + ordinalso a message that somehow carries both legacy blocks and tool edits can never collide two edits onto one runtime key. Not yet verified live against real Word online.Live-verified 2026-08-24 - see the section below.
Rebase 2026-08-24
Rebased onto main 54681b55 (post AI-SDK provider migration). Only the first commit conflicted; both resolutions keep main's design: the assistant error event keeps main's safe_to_display field, and wordChat.ts keeps #365's personalisation-prompt composition with buildWordChatSystemPrompt(clientToolsEnabled) feeding into it. The AI SDK migration preserved the provider-neutral runTools contract, so the bridge hooks in unchanged; main's SSE error sanitizer passes client_tool_call/word_edit_block frames through untouched (no top-level error key).
Live verification on real Word online (2026-08-24)
All scenarios driven against real word.cloud.microsoft with the real Anthropic model (claude-sonnet-5) on the local stack; screenshots/videos recorded:
- Unique edit through the loop -
client_tool_callframes on the/word-chatstream and panePOST /word-chat/tool-resultround trips observed (2 per edit turn: live read + apply); redline proposal card with Apply/View. - Replace-all - one proposal card covering three occurrences, honest multi-step trace in the pane.
- Live read - a sentinel paragraph inserted via Office.js behind the model's back was quoted verbatim when asked what the document says: the answer came from
read_active_document, not the request-time snapshot. - History restore - browser closed, document reopened later, chat reopened from history: both cards restored
ready; Apply on a restored card produced the real Deleted/Added revision pair; Accept resolved it to clean text while the sibling card stayed independently applicable. - Direct mode - with the composer pill on "Edit", the tool call landed the tracked change immediately (no Apply step), card pending accept/reject with a location hint.
🤖 Generated with Claude Code
Our analysis
Move Word edits into the client tool loop — read the full analysis →
Think the analysis missed something the PR description covers?
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-366.md from
inside the repo you want the changes in.