Word add-in: client-executed tool loop for tracked edits - #366
Draft
amal66 wants to merge 8 commits into
Draft
Conversation
|
|
…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
force-pushed
the
olp-pr/word-addin-client-tool-loop
branch
from
August 21, 2026 19:02
9b9b6e9 to
d9097df
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
apply_word_edits/read_active_documentlike any other tool.client_tool_callframe.POST /word-chat/tool-resultendpoint.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_editsassistant 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: truein 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:
{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.{unchanged:true}.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.skip_reason, not the request'sreasonkey.[DONE].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.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)
npm run devinword-addin/; sideload the manifest into Word online and log in.The party shall provide notice.on two separate lines.<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)
/word-chatstream: aclient_tool_callframe forapply_word_edits, followed by the pane'sPOST /word-chat/tool-result(204) carryingstatus: "ambiguous", matches: 2.apply_word_editscall applies, the card shows pending review, and the document carries a genuine tracked change.read_active_document(a live re-read, visible as anotherclient_tool_callframe) instead of trusting the stale request-time snapshot.word_editsevents 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
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 [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.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.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.Word.run, so this window is small in practice.enrichWithPriorEvents(which only covers the last turn, cloud only).Word.runfor the whole call, vs. one per edit on the legacy path — fewer Office.js round trips.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.🤖 Generated with Claude Code
https://claude.ai/code/session_01CcafjUq2U58x21ETkdiwjG