Word add-in: client-executed tool loop for tracked edits

✅ merged · #366 · open-legal-products/mike ← open-legal-products/mike · opened 18d ago by amal66 · merged 14d ago by willchen96 · self · +4,017-138 across 25 files · ↗ on GitHub

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:

  1. The model calls apply_word_edits / read_active_document like any other tool.
  2. The backend doesn't execute it - it forwards the call down the chat's SSE stream as a client_tool_call frame.
  3. The task pane executes it via Office.js (tracked changes / live body read) and POSTs the outcome to the new POST /word-chat/tool-result endpoint.
  4. 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 / ambiguous failures 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-result gets 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-untrackedapplied-unmanaged: Word applies these as tracked changes; the old name plus the prompt's "only claim applied" 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's reason key.
  • 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_edits record; the legacy tag-scraper disarms once a client_tool_call frame 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)

  1. Boot the local stack: repo-root docker compose up -d db auth rest gateway mailpit storage (gateway on :54721 via the root .env), backend npm run dev on :3001 (its .env must point SUPABASE_URL at http://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); sideload word-addin/manifest.xml into Word online and log in.
  2. Create a document containing the same sentence twice, e.g. paste The party shall provide notice. on two separate lines.
  3. In the pane, ask: "Change 'shall provide notice' to 'must provide written notice'."
  4. 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-chat stream contains only content_delta frames - there is no channel by which the failure could reach the model.

PR replication (this branch)

  1. Same setup, same two-line document, same request.
  2. Observe in DevTools → Network on the /word-chat stream: a client_tool_call frame for apply_word_edits, followed by the pane's POST /word-chat/tool-result (204) carrying status: "ambiguous", matches: 2.
  3. The model receives that result mid-response and retries with an extended, unique passage (or asks which occurrence) - the second apply_word_edits call succeeds. In Review mode (composer pill) the tool returns proposed and 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.
  4. 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 another client_tool_call frame) instead of trusting the stale request-time snapshot.
  5. History: reload the pane, reopen the chat - edit cards restore from the persisted word_edits events 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-result POST 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_document over the request-time document_context snapshot is retained (instant, and citation verification depends on it); read_active_document is 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 unknown and 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 one Word.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-alive SSE 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.run for 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 + ordinal so 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:

  1. Unique edit through the loop - client_tool_call frames on the /word-chat stream and pane POST /word-chat/tool-result round trips observed (2 per edit turn: live read + apply); redline proposal card with Apply/View.
  2. Replace-all - one proposal card covering three occurrences, honest multi-step trace in the pane.
  3. 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.
  4. 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.
  5. 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

https://claude.ai/code/session_01CcafjUq2U58x21ETkdiwjG

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.

⬇ Download capture-pull-366.md