Skip to content

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

Draft
amal66 wants to merge 8 commits into
mainfrom
olp-pr/word-addin-client-tool-loop
Draft

Word add-in: client-executed tool loop for tracked edits#366
amal66 wants to merge 8 commits into
mainfrom
olp-pr/word-addin-client-tool-loop

Conversation

@amal66

@amal66 amal66 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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: backend 712 tests green; add-in 246 Playwright e2e green on Chromium and WebKit; both typechecks clean.

Base-case replication (main)

  1. Boot the local stack (API on :3001, Supabase on :54321) and npm run dev in word-addin/; sideload the manifest 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 applies, the card shows pending review, and the document carries a genuine tracked change.
  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 (690 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 [Architecture] feat: durable job queues — BullMQ-primary with Postgres fallback, worker threads, async-on for new installs #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.
  • 120s tool timeout. If the pane never answers (closed pane, crashed Office.js), the model is told the tool failed and the stream continues. Race: a very slow apply that completes after timeout leaves the document changed while the model believes it failed. 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 SSE frames flow; an aggressive proxy idle-timeout could drop the stream. No keep-alive pings were added in this PR.
  • 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. Backend suites, route/bridge/loop tests, and both typechecks are green; the mocked-backend e2e suite is unaffected (legacy rendering path intact). A live Word-online pass on the local stack is the remaining manual gate before marking ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CcafjUq2U58x21ETkdiwjG

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

amal66 and others added 8 commits August 21, 2026 11:10
…rd tools

WHY THIS MATTERS
Word edits are currently fire-and-forget. The model emits an <EDITS> JSON
block in its streamed answer, the task pane parses it out and applies the
edits to the document — and if one fails (text not found, ambiguous match,
already carrying a tracked change), nobody tells the model. It happily
reports success for changes that never happened, or that are still sitting
in a review card waiting on a human. Every compensating heuristic in the
prompt (copy verbatim, keep passages short, ask which occurrence) exists
because the write path has no return channel.

WHAT IS A CLIENT-EXECUTED TOOL
In the web assistant, tools run in the backend because the documents they
touch live in the backend. The active Word document only exists inside the
user's Word session — Office.js in the task pane is the one place that can
touch it. A client-executed tool inverts the execution site while keeping
the loop server-side: the model calls a normal tool; the backend forwards
the call DOWN the chat's SSE stream to the pane (`client_tool_call` frame);
the pane executes it with Office.js and POSTs the outcome UP to
/word-chat/tool-result; the backend feeds that outcome to the model as the
tool result. Same shape as any remote tool execution protocol (e.g. MCP),
applied at the Office.js boundary.

HOW IT WORKS
This commit adds the backend half that has no dependencies on the loop:

- Tool schemas: `apply_word_edits` (a batch of edits, one outcome per edit)
  and `read_active_document` (fresh live text, because the request-time
  snapshot goes stale the moment an edit lands). The edit vocabulary is the
  `<EDITS>` protocol's, field for field — replacement XOR formats, plus the
  explicit occurrence:"all" opt-in — because both channels land in the same
  `word_document_edits` row and are reviewed by the same card. A tool mode
  that could only do plain replacements would quietly drop the ability to
  bold a defined term or promote a line to a heading.
- The pending-call bridge: an in-memory map from a per-call UUID to the
  resolver of the promise the tool loop is awaiting. `submitClientToolResult`
  refuses ids owned by another user, so the POST endpoint cannot be used to
  inject results into someone else's chat. Timeouts resolve with an error
  payload (the model should hear about the failure and adapt, not crash the
  stream); only a genuine stream abort rejects.
- Outcome normalization: whatever the client posts is reduced to one row per
  requested edit, and a missing row becomes an explicit error — the model
  must never be told an edit succeeded when the add-in didn't say so.
- Hints: one line per outcome kind telling the model what to do next, which
  is what closes the self-correction loop.

THE "PROPOSED" OUTCOME
Review mode is the pane's default: it VALIDATES each edit against the live
document, shows a ready card, and waits for the user to click Apply. That
path reports status "proposed", and it is a SUCCESS — the edit is queued for
a human, not rejected by Word. The result payload therefore counts proposed
edits separately from failed ones and carries a hint saying in as many
words: do not retry, do not claim the document changed. Counting a proposal
as a failure is precisely what would send the model into a retry loop
against a document that was never going to change without a click.

Input validation caps originals at 200 characters, not Word's 255-character
search ceiling, because that is the limit of the canonical edit row these
become (`word_document_edits` via PUT /word-chat/messages/:id/edits/:n). An
edit Word could apply but the row could never hold would lose its card on
the next reload.

The bridge is deliberately in-memory and single-instance: the result POST
must reach the process holding the SSE socket, which is already true of
every other piece of streaming state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…amed tags

WHY THIS MATTERS
With the bridge in place, this commit makes the agentic loop actually carry
Word edits as tool calls. Until now the Word chat's only document-aware tool
was read_document over a request-time snapshot; writes bypassed the loop
entirely as an <EDITS> block the model was told to emit into its answer.
That meant no retries, no verification reads, and a system prompt full of
rules trying to compensate for a channel that could not report failure.

WHAT IS THE TOOL LOOP
`runLLMStream` drives `streamChatWithTools`: the model streams text until it
stops with tool calls, the `runTools` callback executes them and returns one
result per call, and the model continues with those results in context.
Because `runTools` is just an async callback, a tool whose execution happens
in another process (the task pane) fits without touching the provider
adapters — the callback simply awaits the bridge.

HOW IT WORKS
- `runLLMStream` accepts an optional `clientTools` adapter
  ({schemas, owns, execute}). Its schemas join the advertised tool list; in
  `runTools`, calls the adapter owns are executed through it (in order,
  sequentially — each call mutates or reads the live document, so order is
  part of the semantics) while the rest flow through the server-side
  dispatcher unchanged. Every tool_use id is answered, in the model's
  original order, merging both result sets.
- The Word route builds the adapter per-request (it closes over the SSE
  writer, the user id, and the stream's abort signal) and enables it only
  when the pane sent `client_tools: true`. Capability flag, not version
  sniffing: an old pane that cannot answer client_tool_call frames keeps the
  streamed <EDITS> prompt and never gets a call it would ignore.
- POST /word-chat/tool-result is the pane's return channel. Malformed ids
  are 400; unknown, expired, and foreign-user ids all answer the same 404 so
  the endpoint can't be probed for live call ids.
- The tool-loop iteration budget rises to 16 for client-tool Word chats: the
  edit flow is built around retry round-trips, and the default 10 can end
  the turn before the model writes its summary.

COMPOSING WITH THE CANONICAL EDIT ROW, NOT AROUND IT
Edits already have a home: `word_document_edits`, keyed by
(message_id, block_index), surfaced as `message.edits`, and placed in
history by a `word_edit_ref` event spliced where the <EDITS> block sat.
Tool edits join that machinery rather than inventing a parallel one.

- The adapter emits one `word_edit_block` placement marker per requested
  edit, at the exact position in the event stream where the tool call landed
  (tool calls land between content blocks, so the marker needs no text to
  splice around). The marker carries the canonical row's fields.
- `persistWordDocumentEdits` — already the finalize-time normalizer for
  <EDITS> — grows a second input channel: markers become rows through the
  same upsert and are replaced by the same `word_edit_ref` events. The
  normalized history a chat replays cannot tell which channel produced a
  card, which is what lets restore, Accept/Reject, and the model-facing
  transcript stay single-implementation.
- Ordinals: tool edits number from block_index 1000 upward so they can never
  collide with prose blocks (0 upward) on one row, and stay under the
  routes' 10_000 ceiling so the pane can persist them through the SAME
  PUT/PATCH endpoints. The pane counts from the same base, and the adapter
  forwards its first ordinal in the call input so a divergence is visible
  rather than silent. A batch rejected at the schema boundary consumes no
  ordinals — the pane never saw it and never counted it.

THE PROMPT
main's Word prompt becomes the non-client-tools variant, byte for byte: the
shared preamble, security rules, and citation contract are extracted so both
variants provably share them, and only the edit section differs. The
client-tools section keeps the full <EDITS> vocabulary — replacement XOR
formats, occurrence:"all", whole-item deletion, heading rules, the
200-character passage limit — and replaces "emit markup and hope" with
"call the tool and read the outcome", including what to do about each
outcome status. Tests pin the byte-identity of the shared halves so a future
prompt edit cannot quietly fork them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
The pane is the only place that can touch the live document, so when the
backend turns a model tool call into a `client_tool_call` SSE frame, the
pane needs three things it never had: a way to receive the call, a way to
post the outcome back, and a way to place the resulting cards in the
transcript before their canonical row ids exist.

HOW IT WORKS
- stream.ts recognizes `client_tool_call` frames and hands them to an
  `onClientToolCall` callback. Passing that callback is also what sets
  `client_tools: true` on POST /word-chat — capability is advertised by the
  code that can actually honour it, so the flag can never drift from the
  implementation.
- client.ts gains postWordChatToolResult(), the return channel keyed by the
  backend's bridge id. A 404 there means the call already expired (timeout
  or aborted stream) and is deliberately treated as a no-op.
- wordTrackedEditKeys.ts introduces TOOL_EDIT_INDEX_BASE (1000): tool edits
  key their card state, hidden bookmarks, and persisted rows from that
  offset, so a message that carries both streamed <EDITS> blocks and tool
  edits can never collide two different edits onto one key. The backend
  counts from the same base and forwards its first ordinal with each call.
  The base sits under the edit routes' 10_000 ceiling on purpose: that is
  what lets tool edits use the SAME PUT/PATCH persistence and the SAME
  message.edits restore path as streamed ones, instead of a parallel one.
- types.ts adds `word_edit_block`, the live placement marker. A turn learns
  an edit exists when the tool call is forwarded — before the canonical row
  (and its id) exists — so placement keys on the block index until the
  finalizer swaps in a `word_edit_ref`. Stored events are normalized back
  into it for the one path where a finalizer could not run.

WHAT THE MODEL READS BACK
`assistantContentForModel` rehydrates a prior turn's edit references for the
model's private transcript. Streamed edits keep rehydrating as an <EDITS>
block, but tool edits must not: they never appeared in the answer text, and
replaying them as markup would both teach the wrong protocol for this mode
and read as if the model had emitted something it never emitted. They
instead become a short factual summary — what was sent, and whether each one
applied, failed, or is still awaiting the user's review — bounded to 20
lines and 120-character excerpts so one 50-edit turn cannot plant a
half-megabyte block in every later request. The split is by block-index
space, which is exactly why that space is disjoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… truth

WHY THIS MATTERS
This is the half that closes the loop: the pane executes the forwarded tool
call against the live document and posts back exactly what happened. The
model finally learns that an edit was not-found or ambiguous while it is
still generating, and can retry with a corrected passage — the failure modes
that previously produced silent no-op edit cards. And in Review mode it
learns the thing it could never learn before: that the change is real,
validated, and waiting on a human, so it should say so instead of claiming
the document changed.

REUSING THE CARD LIFECYCLE INSTEAD OF FORKING IT
`applyToolEdits` schedules each requested edit through the SAME code path a
streamed <EDITS> block takes — scheduleReviewEdit in Review mode (validate,
settle the card on "ready", leave the document untouched), scheduleDirectEdit
in Edit mode (apply as a tracked change) — keyed by the tool block index.
Persistence, replace-all passes, the conflicted card's Accept & apply, the
bookmark anchor, and history restore therefore all work on tool edits for
free, because to every one of them a tool edit IS an edit.

The one genuinely new capability is reading each card's settled status back
out. Card state is React state, which is not readable at the instant a job
resolves, so the hook now keeps a synchronous mirror written by the single
commit point every update already went through. The mirror is what lets a
settled card become a truthful wire outcome: pending → "applied",
unmanaged → "applied-unmanaged", ready → "proposed", ambiguous/skipped/
unsearchable/conflicted → their failure statuses with Word's own reason. An
unsettled key reports an error, never a success.

HOW A CALL RUNS
- useWordAssistantChat wires onClientToolCall. read_active_document answers
  with the same structure-annotated markdown the request snapshot uses, so
  the model's before and after views of the document are in one format.
- apply_word_edits parses the batch all-or-nothing (the backend only
  forwards fully-valid batches, so a bad row means a protocol mismatch, and
  applying a subset would leave the two sides counting different ordinals),
  takes its ordinals from the backend's forwarded block_index — one
  authority rather than two counters that can drift — appends the live card
  rows and their placement markers, runs the batch, stamps the settled
  outcome onto those rows, and posts the outcomes aligned to the backend's
  request indices.
- A call arriving after the user switched chats is refused without touching
  or even reading the document: the transcript record and the document
  interaction must live or die together.
- The <EDITS> scraper is disarmed for a turn once a client_tool_call frame
  arrives. With tools active, an <EDITS> block in the prose is quoted text,
  and scraping it would double-apply edits on top of the tool channel. It
  stays armed when no tool call ever arrives, which keeps a new pane working
  against an older backend.
- The terminal saves await in-flight tool calls, so the persisted turn always
  carries final per-edit statuses.

PROSE KEEPS STREAMING
Holding the answer text until edits settle exists for edits embedded IN that
text. Tool edits live outside the prose, so the hold now keys on projected
(in-prose) edits only — otherwise the model's preamble would vanish from
screen for the whole time a batch validates, and the summary would pop in at
the end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An adversarial review of the client-tool loop found ways the new channel
could lie to the model, lose the record of applied edits, or accept input
Word can never act on. This commit closes them.

WHY THIS MATTERS
The whole point of the redesign is that the model's picture of the document
matches reality. Three scenarios broke that promise:

1. TIMEOUTS INVITED DOUBLE-APPLIES. A timed-out apply_word_edits was
   reported as a definite failure — but a slow Word.run may have landed the
   edits anyway. The prompt says "retry only the failed edits", so the model
   would re-apply, stacking a second tracked change over the first. Timeouts
   now produce a distinct "unknown" status with a hint to verify via
   read_active_document first, and are counted as "unconfirmed", never
   "failed". Only the backend may synthesize it: the bridge's timeout and
   cancel results are module-private sentinel objects matched BY REFERENCE,
   so a wire payload can copy the shape but can never be identity-equal, and
   a client row that simply claims "unknown" is treated as an error.

2. ABORTS ORPHANED APPLIED EDITS. Hitting Stop mid-apply threw out of the
   adapter before its placement markers reached the persisted partial turn —
   yet the pane's tracked changes and hidden bookmarks were already in the
   document, unrecoverable by any card. The adapter now catches the abort
   and returns the markers normally; the loop records client results BEFORE
   its abort check, so the partial turn keeps the cards and history restore
   can probe each bookmark for survivors.

3. THE LIVE READ WAS UNCAPPED. read_active_document strings flowed into the
   model prompt bounded only by the global body limit. Live reads now
   truncate at the same 200k ceiling as the snapshot path.

SMALLER FIXES FROM THE SAME REVIEW
- Input Word can never search (line breaks, "^") is rejected at the schema
  boundary with an actionable message instead of round-tripping to Word only
  to come back as an unexplained skip. Word's own skip reasons
  (pre-existing-revisions, unsearchable) now carry per-reason hints, so a
  bare "skipped" never strands the model — and hints key on the reason, not
  the status, so two different skips get two different instructions.
- The apply deadline scales with batch size (30s + 3s/edit, max 180s). A
  flat 60s looks generous but Word Online pays several context.sync() host
  round trips PER edit; timing a big batch out produced exactly the
  unconfirmed-retry ambiguity the timeout was meant to avoid. The base wait
  for everything else drops from 120s to 60s — a live pane answers in
  seconds, and every extra second holds the SSE socket and model turn open.
- SSE comment keep-alives flow every 15s while the backend waits on the
  pane, so proxy idle timeouts can't drop the stream mid-tool-call.
- forwardCall settles its just-registered bridge entry if the frame write
  itself fails (today's res.write never throws — the module no longer
  depends on that).
- A failed result POST retries once and passes the stream's abort signal; a
  404 (expired call) stays silent by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS
The add-in has no unit-test runner — its only automated verification is the
hermetic Playwright suite (production bundle + Office.js shim + fully mocked
network). Until now that suite exercised only the streamed <EDITS> protocol,
while a shipped pane always advertises client_tools and therefore always
takes the new path in production. Whatever the suite proved, it wasn't the
code users run.

WHAT THE HARNESS DOES
The shared fixture's mockChatStream gains a `clientToolCalls` option that
injects `client_tool_call` frames into the mocked SSE body, exactly as the
backend forwards them. Specs mock POST /word-chat/tool-result and capture
the pane's outcome POST, so the full loop — frame in, Office.js execution
against the mock document, truthful result out — runs in a real browser with
zero live services.

WHAT IS PINNED
- Capability advertisement: the chat POST carries client_tools: true.
- The mode split, which is the heart of the design. In Review (the default)
  the tool call VALIDATES: the outcome posts {status:"proposed", matches:1},
  the document is untouched, and the card offers Apply/View — then clicking
  Apply writes the tracked change through the ordinary lifecycle. In Edit
  mode the same call posts {status:"applied"} and the change is in the
  document immediately.
- Failure truthfulness in both modes: an ambiguous original posts
  {status:"ambiguous", matches:2}, touches nothing, and the card explains
  how many places matched.
- Live reads: read_active_document answers with the document body.
- Persistence: a tool edit PUTs the SAME canonical edit row a streamed edit
  does, at block index 1000 — the composition claim, asserted rather than
  assumed.
- Ordinals: two sequential apply calls register bookmarks edit-1000..1002,
  the contract that makes the pane, the backend's persisted markers, and
  history restore count identically.
- Disarmed scraper: an <EDITS> block in the prose of a tool-mode turn
  produces no second tracked change.
- Return-channel behavior: exactly one POST on a 404 (expired call), and a
  500-then-204 retry.
- Restore: after a reload, an applied tool edit comes back reviewable
  through message.edits, and a failed one still explains itself instead of
  collapsing to a generic historical card.
- Prose is visible while a tool edit is on screen — the hold that exists for
  in-prose edits must not blank a tool turn's answer.

Both Playwright projects run these: Chromium and WebKit (the WKWebView proxy
for Word on Mac).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A three-lens second review round (backend concurrency, client/Office.js,
model-facing protocol) audited both the feature AND the first round's fixes.
This commit lands the backend share of what it found.

BOUNDING WHAT A TURN CAN COST
- A wedged pane (dead task-pane JS, open socket) used to burn up to 16
  iterations x full deadline, each a held SSE socket plus a paid model call.
  The adapter now stops forwarding after two consecutive timeouts: a pane
  that stopped answering does not wake up mid-turn.
- A per-turn budget of 12 client calls errors out with "summarize now"
  BEFORE the provider loop's iteration ceiling — which truncates silently,
  after the document was already mutated — can be hit.
- read_active_document was an unbounded context amplifier: each retry could
  re-inject a 200k-char body that then rides every remaining iteration. Live
  reads now cap at 3 per turn, and a byte-identical re-read returns
  {unchanged:true} instead of the body.
- POST /word-chat/tool-result gets its own rate-limit lane (own limiter,
  exempt from the shared 300/15min general budget that office users behind
  one NAT would drain) and its own 2mb body parser mounted ahead of the
  global 50mb one. The posted edits array is additionally sliced and
  index-bounded before it becomes Map entries, so a pathological body cannot
  balloon into millions of entries or answer for an edit nobody requested.

CLOSING PROTOCOL SEAMS THE MODEL WOULD TRIP ON
- The tool schema carries maxLength caps and states the batch limit in prose.
  Providers strip minItems/maxItems — Gemini's schema allowlist keeps only a
  subset — so the description is the only load-bearing signal for the
  batch-size rule, and parseWordEditsInput is what actually enforces it.
- Prior-turn activity lines label a live read as read_active_document instead
  of naming a read_document handle that does not exist. That is why the live
  read carries its own filename in the first place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… saves

The pane's share of the second review round. The theme is the same as the
backend commit's: what the user sees and what the model reads must both stay
truthful at every boundary — reload, cancel, timeout.

A "PROPOSED" ROW IS A CLAIM, NOT EVIDENCE
Restoring a chat believes what storage says: apply_status "proposed" means
the change was never written, so the pane re-validates it and offers Apply.
That is right when the turn finished. It is wrong exactly when it matters —
a turn that died mid-apply (a cancelled stream, a client tool call that
timed out, a status write that never landed) leaves the tracked change AND
its hidden bookmark in the document with the row still reading "proposed".

Re-validating such an edit finds its own revision sitting in the target
passage, reports "pre-existing-revisions", and hands the user an
"Accept & apply" button that writes the same change a second time.

Restore now asks the document first: every proposed edit's bookmark is
probed in one batched pass, and a bookmark that is still there wins over the
row. Those cards come back as ordinary pending changes with Accept/Reject,
and the row is corrected to "applied" so no other reader — including the
next reload and the model's replayed transcript — is told the change never
happened. Only edits the document does not know about fall through to
validation, exactly as before. A conflicted validation result now also
retains its retry arguments, so its card's "Accept & apply" works on a
reloaded chat the same way it does on a live one.

This lands for BOTH edit channels, because both are the same row: a streamed
<EDITS> proposal whose status write failed benefits identically.

ALREADY TRUE, AND WORTH SAYING WHY
Three seams the tool loop would otherwise have broken are covered by
earlier commits in this series rather than here, because composing with the
canonical edit row made them fall out:

- Prose streaming in tool mode: the answer-hold now keys on in-prose edits
  only, so a validating batch no longer blanks the model's preamble.
- Turn-boundary saves: the terminal paths await in-flight tool calls before
  persisting, so a saved turn always carries final per-edit statuses, and a
  cancelled turn whose only activity was a tool apply still saves — its
  placement markers are events, and the existing abort-path save already
  triggers on any event.
- A failed outcome POST logs once and stops; the transcript summary the
  model replays is bounded to 20 lines and 120-character excerpts.

WHAT THE ORIGINAL BRANCH DID HERE THAT IS NOW MOOT
Batched anchor persistence (one settings write per apply batch) existed
because the branch applied a whole tool batch inside one Word.run. Tool
edits now run through the per-edit card lifecycle instead — the same one a
streamed edit uses — so there is no batch to coalesce and the optimization
would be dead code. The cost is real (one Word.run per edit rather than one
per batch) and is exactly what main already pays for a 50-edit <EDITS>
block; making both channels faster is one change to one code path, not two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@amal66
amal66 force-pushed the olp-pr/word-addin-client-tool-loop branch from 9b9b6e9 to d9097df Compare August 21, 2026 19:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants