From f661670270423ddb92f2ef0fef43218e0ffbc956 Mon Sep 17 00:00:00 2001 From: homen Date: Wed, 14 Oct 2026 03:35:50 -0700 Subject: [PATCH 1/2] Stop cancelled chat runs before emitting frames or writing graph state --- CHANGELOG/runtime/node-agent-chat-adapter.md | 63 ++++ .../runtime/nodeAgentChatAdapter.ts | 52 ++-- tests/nodeAgentChatAdapter.test.ts | 272 ++++++++++++++++++ 3 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 CHANGELOG/runtime/node-agent-chat-adapter.md create mode 100644 tests/nodeAgentChatAdapter.test.ts diff --git a/CHANGELOG/runtime/node-agent-chat-adapter.md b/CHANGELOG/runtime/node-agent-chat-adapter.md new file mode 100644 index 0000000..b0009cc --- /dev/null +++ b/CHANGELOG/runtime/node-agent-chat-adapter.md @@ -0,0 +1,63 @@ +# NodeAgent chat adapter + +## 2026-10-14 — stop at the cancellation boundary + +An analyst can stop a response after a tool completes without the adapter later +starting another tool or changing the session graph. Earlier completed response +content remains available, and a fresh retry can complete normally. + +The adapter previously returned only from an inner step when cancelled; the +outer generator resumed and recorded graph work. Its other delays never checked +cancellation. The existing delay now rejects on abort and releases its timer +and listener. Every frame checks cancellation before emission, and the shared +step checks immediately before its synchronous graph write. The cancellation +reason unwinds the whole run and the installed assistant-ui runtime reports it +as cancelled. +Pre-aborted runs stop before computing or emitting a response. + +Validation: + +- The original 41 adapter scenarios against source `c6de499` produced 40 + failures and one passing normal-run control. The final suite has 54 scenarios. +- All nine delay boundaries, reduced-motion delays, paused yields, timer-fired + races before and after the delay resolves, retained history, retry, overlapping + runs, a 16-run cancellation burst, and 128 sequential cancellations with + periodic successful retries are covered. Four installed-SDK scenarios verify + cancelled status, retained tool results, and unchanged graph state together. +- `npm test -- tests/nodeAgentChatAdapter.test.ts tests/nodeAgentRuntime.test.ts + tests/reasoningFrameRunner.test.ts tests/nodeAgentEventProtocol.test.ts` passes + 67/67 tests. `npm run typecheck` passes. +- The direct pending-delay probe previously added six graph nodes, eleven edges, + and the next tool frame after Stop. It now rejects with `AbortError`, retains + the exact graph snapshot, and emits no next-stage frame. + +Browser verification and independent review are separate acceptance work. No +provider smoke, native SQLite tests, model changes, or visual changes are part +of this patch. The generated chat template has a similar cancellation pattern +and remains outside this source-adapter patch. + +**Re your request:** improve the in-app agent experience — stopping a response +now ends its asynchronous progress instead of allowing late work to appear. + +### Browser checkpoint — 2026-10-14 + +Chrome on the local deterministic demo retained the first completed memo after +Stop on a second request. The stopped graph stayed at 13 entities / 27 edges; +Retry completed and displayed both memos. The browser stop occurred during +search; exact post-tool timing is covered by the direct adapter scenarios. +Observed console warnings/errors came from installed browser extensions. +This is local behavior evidence, not a production deployment claim. + +### Independent review correction — attempt 2 + +The first candidate (`e96872d196ee2b074f5dddda1c7bae836b309e9622def1a69776a6ea9d1bf26b`) +passed the initial suite but still wrote graph state if Stop arrived after the +delay's own continuation and before its caller resumed. The SDK hid the late +frame and reported cancelled, masking the graph change. Adding one microtask +before Stop reproduced 13 failures: all nine adapter waits and all four SDK +post-tool boundaries. Those exact cases now pass. Guarding the actual frame +and graph-write boundaries replaces the insufficient check inside the delay; +graph writes share one guarded step instead of four separate outer branches. +The first candidate's source, tests, and proof logs remain preserved in the +campaign evidence directory. Exact timing is SDK/test evidence, not browser +timing certification. diff --git a/src/features/node-agent/runtime/nodeAgentChatAdapter.ts b/src/features/node-agent/runtime/nodeAgentChatAdapter.ts index 60b44e1..ab78a48 100644 --- a/src/features/node-agent/runtime/nodeAgentChatAdapter.ts +++ b/src/features/node-agent/runtime/nodeAgentChatAdapter.ts @@ -59,6 +59,7 @@ function lastUserText(messages: readonly { role: string; content: readonly unkno export const nodeAgentChatAdapter: ChatModelAdapter = { async *run({ messages, abortSignal }) { + abortSignal.throwIfAborted(); const question = lastUserText(messages) || DEMO_QUESTION; // Run the real loop, then derive the post-delta model for the model tool UI. @@ -71,20 +72,36 @@ export const nodeAgentChatAdapter: ChatModelAdapter = { } const reduced = prefersReducedMotion(); - const tick = (ms: number) => - new Promise((res) => setTimeout(res, reduced ? 0 : ms)); + const tick = async (ms: number) => { + abortSignal.throwIfAborted(); + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + abortSignal.removeEventListener("abort", onAbort); + reject(abortSignal.reason); + }; + const timer = setTimeout(() => { + abortSignal.removeEventListener("abort", onAbort); + resolve(); + }, reduced ? 0 : ms); + abortSignal.addEventListener("abort", onAbort, { once: true }); + }); + }; // Ordered map of tool-call parts; each yield replaces content cumulatively. const parts = new Map>(); let lead = "On it — pulling the room context, then searching, updating the model, and writing the memo."; - const snapshot = () => ({ - content: [ - { type: "text" as const, text: lead }, - ...(Array.from(parts.values()) as never[]), - ], - }); + const snapshot = () => { + abortSignal.throwIfAborted(); + return { + content: [ + { type: "text" as const, text: lead }, + ...(Array.from(parts.values()) as never[]), + ], + }; + }; // Each step: show the tool running, then complete with its real result. async function* step( @@ -92,14 +109,17 @@ export const nodeAgentChatAdapter: ChatModelAdapter = { toolName: string, args: Record, toolResult: unknown, + recordGraph: () => void, ) { parts.set(id, { type: "tool-call", toolCallId: id, toolName, args }); yield snapshot(); - if (abortSignal.aborted) return; await tick(360); parts.set(id, { type: "tool-call", toolCallId: id, toolName, args, result: toolResult }); yield snapshot(); await tick(160); + // Check where shared state changes, with no asynchronous gap before the write. + abortSignal.throwIfAborted(); + recordGraph(); } yield snapshot(); @@ -109,19 +129,19 @@ export const nodeAgentChatAdapter: ChatModelAdapter = { // actually touched (see agentGraphSession.ts for the honesty contract). const graphRunId = nextGraphRunId(); - yield* step("ctx", "collect_context", { focus: question }, result.context as ContextToolResult); - feedGatherStep(graphRunId, scenario.room, result); - yield* step("search", "search_synthesize", { query: question }, result.synthesis as SearchToolResult); - feedSearchStep(graphRunId, result); + yield* step("ctx", "collect_context", { focus: question }, result.context as ContextToolResult, + () => feedGatherStep(graphRunId, scenario.room, result)); + yield* step("search", "search_synthesize", { query: question }, result.synthesis as SearchToolResult, + () => feedSearchStep(graphRunId, result)); yield* step( "model", "apply_spreadsheet_delta", { sheet: model.name }, { applied: result.modelDelta, model } as ModelToolResult, + () => feedModelStep(graphRunId, model, result), ); - feedModelStep(graphRunId, model, result); - yield* step("memo", "write_memo", { title: result.memo.title }, result.memo as MemoToolResult); - feedMemoStep(graphRunId, result); + yield* step("memo", "write_memo", { title: result.memo.title }, result.memo as MemoToolResult, + () => feedMemoStep(graphRunId, result)); // Closing line — honest about the run. const runway = result.modelDelta?.changes.find((c) => c.address === "B3"); diff --git a/tests/nodeAgentChatAdapter.test.ts b/tests/nodeAgentChatAdapter.test.ts new file mode 100644 index 0000000..07a4eb6 --- /dev/null +++ b/tests/nodeAgentChatAdapter.test.ts @@ -0,0 +1,272 @@ +import { getEventListeners } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatModelRunResult, ThreadMessage } from "@assistant-ui/react"; +import { DEMO_QUESTION } from "../src/features/node-agent/demoScenario"; + +type Run = AsyncGenerator; +let adapter: typeof import("../src/features/node-agent/runtime/nodeAgentChatAdapter").nodeAgentChatAdapter; +let graph: typeof import("../src/features/node-agent/graph/agentGraphSession").graphSession; + +const boundaries = [ + ["before context collection", 1], + ["during context collection", 2], + ["after context completes", 3], + ["during source search", 4], + ["after sources complete", 5], + ["during spreadsheet work", 6], + ["after the spreadsheet completes", 7], + ["during memo writing", 8], + ["after the memo completes", 9], +] as const; + +function user(text: string): ThreadMessage { + return { + id: text, role: "user", content: [{ type: "text", text }], + createdAt: new Date("2026-10-14T00:00:00Z"), attachments: [], metadata: { custom: {} }, + }; +} + +function start(controller = new AbortController(), messages = [user(DEMO_QUESTION)]) { + const run = adapter.run({ + messages, abortSignal: controller.signal, runConfig: {}, context: {}, + unstable_getMessage: () => messages[messages.length - 1], + }); + if (!("next" in run)) throw new Error("The chat adapter must stream its response"); + return { run, controller }; +} + +async function next(run: Run) { + const pending = run.next(); + await vi.runAllTimersAsync(); + return pending; +} + +async function advance(run: Run, count: number) { + const frames: ChatModelRunResult[] = []; + for (let i = 0; i < count; i++) { + const result = await next(run); + if (result.done) throw new Error("Response finished before the requested stop boundary"); + frames.push(result.value); + } + return frames; +} + +async function complete(run: Run) { + const frames: ChatModelRunResult[] = []; + for (let i = 0; i < 12; i++) { + const result = await next(run); + if (result.done) return frames; + frames.push(result.value); + } + throw new Error("The four-tool response did not finish within its frame budget"); +} + +const snapshot = () => structuredClone(graph.getSnapshot()); +const observe = (pending: ReturnType) => pending.then( + value => ({ value, error: undefined }), + (error: unknown) => ({ value: undefined, error }), +); + +async function expectStopped(run: Run, controller: AbortController, pending: ReturnType, before: ReturnType) { + await vi.runAllTimersAsync(); + const result = await pending; + expect(result.error).toMatchObject({ name: "AbortError" }); + expect(result.value).toBeUndefined(); + expect(snapshot()).toEqual(before); + expect((await run.next()).done).toBe(true); + expect(vi.getTimerCount()).toBe(0); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); +} + +beforeEach(async () => { + vi.useFakeTimers(); + vi.resetModules(); + adapter = (await import("../src/features/node-agent/runtime/nodeAgentChatAdapter")).nodeAgentChatAdapter; + graph = (await import("../src/features/node-agent/graph/agentGraphSession")).graphSession; +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("an analyst stops a response without late work or lost earlier results", () => { + it("completes all four tools and releases waits when the analyst lets the response finish", async () => { + const session = start(); + const frames = await complete(session.run); + const result = frames.at(-1)!; + expect(result.content?.[0]).toMatchObject({ text: expect.stringMatching(/^Done\./) }); + expect(result.content?.filter(part => part.type === "tool-call" && part.result !== undefined)).toHaveLength(4); + expect(snapshot().nodes.some(node => node.type === "memo")).toBe(true); + expect(getEventListeners(session.controller.signal, "abort")).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("never starts a response whose cancellation arrived before the first frame", async () => { + const session = start(); + const before = snapshot(); + session.controller.abort(); + await expectStopped(session.run, session.controller, observe(session.run.next()), before); + }); + + describe.each([false, true])("with reduced motion %s", reduced => { + it.each(boundaries)("stops a pending delay %s", async (_boundary, frames) => { + vi.stubGlobal("window", { matchMedia: () => ({ matches: reduced }) }); + const session = start(); + const completed = await advance(session.run, frames); + const retained = structuredClone(completed); + const before = snapshot(); + const pending = observe(session.run.next()); + expect(vi.getTimerCount()).toBe(1); + session.controller.abort(); + await expectStopped(session.run, session.controller, pending, before); + expect(completed).toEqual(retained); + }); + }); + + it.each(boundaries)("stays stopped when the consumer resumes a paused frame %s", async (_boundary, frames) => { + const session = start(); + await advance(session.run, frames); + const before = snapshot(); + session.controller.abort(); + await expectStopped(session.run, session.controller, observe(session.run.next()), before); + }); + + it.each(boundaries)("honors Stop when a timer has fired but the continuation has not resumed %s", async (_boundary, frames) => { + const session = start(); + await advance(session.run, frames); + const before = snapshot(); + const pending = observe(session.run.next()); + vi.runOnlyPendingTimers(); + session.controller.abort(); + await expectStopped(session.run, session.controller, pending, before); + }); + + it.each(boundaries)("stops before publishing work when the wait has already resolved %s", async (_boundary, frames) => { + const session = start(); + await advance(session.run, frames); + const before = snapshot(); + const pending = observe(session.run.next()); + vi.runOnlyPendingTimers(); + // The delay's own continuation runs first; Stop arrives before its caller resumes. + await Promise.resolve(); + const reason = Object.assign(new Error("Analyst stopped this response"), { name: "AbortError" }); + session.controller.abort(reason); + await expectStopped(session.run, session.controller, pending, before); + expect((await pending).error).toBe(reason); + }); + + it.each(boundaries.filter(([, frames]) => frames > 1 && frames % 2 === 1))( + "keeps the SDK's cancelled response and graph consistent %s", + async (_boundary, frames) => { + const { LocalThreadRuntimeCore } = await import("@assistant-ui/core/internal"); + let signal: AbortSignal | undefined; + const runtime = new LocalThreadRuntimeCore({ getModelContext: () => ({}) }, { + adapters: { chatModel: { run: options => { + signal = options.abortSignal; + return adapter.run(options); + } } }, + }); + const pending = runtime.append({ ...user(DEMO_QUESTION), parentId: null, sourceId: null, runConfig: {} }); + const flush = async () => { for (let i = 0; i < 50; i++) await Promise.resolve(); }; + await flush(); + for (let i = 1; i < frames; i++) { + vi.runOnlyPendingTimers(); + await flush(); + } + const before = snapshot(); + const retained = structuredClone(runtime.messages.at(-1)!.content); + expect(retained.filter(part => part.type === "tool-call" && part.result !== undefined)).toHaveLength((frames - 1) / 2); + expect(vi.getTimerCount()).toBe(1); + vi.runOnlyPendingTimers(); + await Promise.resolve(); + runtime.cancelRun(); + await pending; + expect(runtime.messages.at(-1)!.status).toEqual({ type: "incomplete", reason: "cancelled" }); + expect(runtime.messages.at(-1)!.content).toEqual(retained); + expect(snapshot()).toEqual(before); + expect(signal?.reason).toMatchObject({ name: "AbortError" }); + expect(getEventListeners(signal!, "abort")).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it("keeps an earlier completed turn and lets the analyst retry the stopped question", async () => { + const earlier = start(); + const earlierFrames = await complete(earlier.run); + const previous: ThreadMessage = { + id: "earlier-response", role: "assistant", createdAt: new Date("2026-10-14T00:00:00Z"), + content: earlierFrames.at(-1)!.content!, status: { type: "complete", reason: "stop" }, + metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }, + }; + const history = [user(DEMO_QUESTION), previous, user(`${DEMO_QUESTION} Please recheck.`)]; + const retained = structuredClone(history); + const stopped = start(new AbortController(), history); + await advance(stopped.run, 3); + const before = snapshot(); + const pending = observe(stopped.run.next()); + stopped.controller.abort(); + await expectStopped(stopped.run, stopped.controller, pending, before); + const retry = start(new AbortController(), history); + const frames = await complete(retry.run); + expect(frames.at(-1)?.content?.[0]).toMatchObject({ type: "text", text: expect.stringMatching(/^Done\./) }); + expect(frames.at(-1)?.content?.filter(part => part.type === "tool-call" && part.result !== undefined)).toHaveLength(4); + expect(history).toEqual(retained); + expect(getEventListeners(earlier.controller.signal, "abort")).toHaveLength(0); + expect(getEventListeners(retry.controller.signal, "abort")).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels one overlapping analyst run without cancelling or attributing work to the other", async () => { + const stoppedQuestion = `${DEMO_QUESTION} Cancelled analyst.`; + const continuingQuestion = `${DEMO_QUESTION} Continuing analyst.`; + const stopped = start(new AbortController(), [user(stoppedQuestion)]); + const continuing = start(new AbortController(), [user(continuingQuestion)]); + await advance(stopped.run, 3); + await advance(continuing.run, 3); + const cancelled = observe(stopped.run.next()); + const active = continuing.run.next(); + stopped.controller.abort(); + await vi.runAllTimersAsync(); + expect((await cancelled).error).toMatchObject({ name: "AbortError" }); + expect((await active).done).toBe(false); + const frames = await complete(continuing.run); + expect(frames.at(-1)?.content?.[0]).toMatchObject({ text: expect.stringMatching(/^Done\./) }); + const questions = snapshot().nodes.filter(node => node.type === "question").map(node => node.label); + expect(questions).toEqual([continuingQuestion]); + for (const session of [stopped, continuing]) expect(getEventListeners(session.controller.signal, "abort")).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("releases every wait after a burst of stops and a long session of repeated stop/retry work", async () => { + const burst = Array.from({ length: 16 }, () => start()); + for (const session of burst) await advance(session.run, 3); + const beforeBurst = snapshot(); + const pending = burst.map(session => observe(session.run.next())); + burst.forEach(session => session.controller.abort()); + await vi.runAllTimersAsync(); + for (let i = 0; i < burst.length; i++) { + await expectStopped(burst[i].run, burst[i].controller, pending[i], beforeBurst); + } + for (let i = 0; i < 128; i++) { + const session = start(); + await advance(session.run, boundaries[i % boundaries.length][1]); + const before = snapshot(); + const result = observe(session.run.next()); + session.controller.abort(); + await expectStopped(session.run, session.controller, result, before); + if (i % 16 === 15) { + const retry = start(); + const frames = await complete(retry.run); + expect(frames.at(-1)?.content?.[0]).toMatchObject({ text: expect.stringMatching(/^Done\./) }); + expect(getEventListeners(retry.controller.signal, "abort")).toHaveLength(0); + } + } + const stats = graph.stats(); + expect(stats.nodes).toBeLessThanOrEqual(stats.maxNodes); + expect(stats.edges).toBeLessThanOrEqual(stats.maxEdges); + expect(stats.seen).toBeLessThanOrEqual(stats.maxSeen); + expect(vi.getTimerCount()).toBe(0); + }); +}); From 8fd75806e4a3423e427bccd493a8eabe82db37d5 Mon Sep 17 00:00:00 2001 From: homen Date: Wed, 14 Oct 2026 03:47:23 -0700 Subject: [PATCH 2/2] Keep cancellation walkthroughs aligned with the guarded runtime --- .tours/01-primary-user-flow.tour | 8 +++--- .tours/03-debug-and-recovery.tour | 8 +++--- CHANGELOG/runtime/node-agent-chat-adapter.md | 15 ++++++++++ docs/START_HERE.md | 30 +++++++++++++------- scripts/build-tours.mjs | 6 ++-- 5 files changed, 46 insertions(+), 21 deletions(-) diff --git a/.tours/01-primary-user-flow.tour b/.tours/01-primary-user-flow.tour index de62416..5b3515e 100644 --- a/.tours/01-primary-user-flow.tour +++ b/.tours/01-primary-user-flow.tour @@ -41,7 +41,7 @@ }, { "file": "src/features/node-agent/runtime/nodeAgentChatAdapter.ts", - "line": 66, + "line": 67, "anchor": "const result = runNodeAgent(scenario)", "description": "The surprise worth knowing early: this call is SYNCHRONOUS and all four steps finish right here, before anything renders. The streaming a user sees is this function pacing out results that already exist." }, @@ -59,13 +59,13 @@ }, { "file": "src/features/node-agent/runtime/nodeAgentChatAdapter.ts", - "line": 112, + "line": 132, "anchor": "yield* step(\"ctx\", \"collect_context\"", "description": "Tool INVOCATION, using the exact same name string. If these two lists ever disagree the card silently does not render and the run still reports success. Nothing enforces the match." }, { "file": "src/features/node-agent/runtime/nodeAgentChatAdapter.ts", - "line": 96, + "line": 114, "anchor": "parts.set(id, { type: \"tool-call\", toolCallId: id, toolName, args })", "description": "How a card appears then fills in: yield the call (card shows 'working…'), pause, yield the same call plus its result (card shows the real data)." }, @@ -83,7 +83,7 @@ }, { "file": "src/features/node-agent/runtime/nodeAgentChatAdapter.ts", - "line": 127, + "line": 147, "anchor": "const runway = result.modelDelta?.changes.find", "description": "The closing line quotes real numbers off the result — the grounded count and the recomputed runway — rather than a canned success message." } diff --git a/.tours/03-debug-and-recovery.tour b/.tours/03-debug-and-recovery.tour index b8494be..a9e6572 100644 --- a/.tours/03-debug-and-recovery.tour +++ b/.tours/03-debug-and-recovery.tour @@ -1,7 +1,7 @@ { "$schema": "https://aka.ms/codetour-schema", "title": "3 · When it breaks: what catches it, and what does not", - "description": "Failure handling, the two gaps you must know about before your first change, and how to make every check in this repo go red on purpose.", + "description": "Failure handling, cancellation boundaries, the remaining render-error gap, and how to make every check in this repo go red on purpose.", "steps": [ { "file": "src/features/node-agent/runtime/nodeAgentRuntime.ts", @@ -11,9 +11,9 @@ }, { "file": "src/features/node-agent/runtime/nodeAgentChatAdapter.ts", - "line": 98, - "anchor": "if (abortSignal.aborted) return", - "description": "The abort signal is checked BETWEEN steps only — because the loop already finished synchronously before this generator started yielding. Nothing in the UI is wired to trigger it (defect D3)." + "line": 122, + "anchor": "recordGraph();", + "description": "When an analyst presses Stop response, the shared step checks cancellation immediately before this synchronous graph write. Pending waits reject and every snapshot checks before emission, so later graph updates and response frames stop together. The installed assistant-ui runtime catches AbortError and reports cancelled. The domain loop already ran synchronously; stopping does not undo its computation or earlier completed work." }, { "file": "src/features/node-agent/components/NodeAgentDemoApp.tsx", diff --git a/CHANGELOG/runtime/node-agent-chat-adapter.md b/CHANGELOG/runtime/node-agent-chat-adapter.md index b0009cc..337e000 100644 --- a/CHANGELOG/runtime/node-agent-chat-adapter.md +++ b/CHANGELOG/runtime/node-agent-chat-adapter.md @@ -61,3 +61,18 @@ graph writes share one guarded step instead of four separate outer branches. The first candidate's source, tests, and proof logs remain preserved in the campaign evidence directory. Exact timing is SDK/test evidence, not browser timing certification. + +### Documentation integration — attempt 3 + +The approved cancellation code moved four cited tour anchors and removed the old +inner-generator abort branch. CI tour validation also caught an outdated +`START_HERE` snippet. Regenerating alone was insufficient: the tour generator +still said there was no Stop control, although the generated primary tour had +been manually corrected earlier. The generator now preserves that Stop/Retry +description and anchors recovery guidance at the guarded synchronous graph +write. Regenerated tours and the walkthrough describe pending-wait cleanup, +frame and graph boundaries, SDK cancellation status, and retained results. +Runtime and scenario-test contents are unchanged from candidate 2. +`npm run tours:validate` passes all 3 tours, 36 steps, and 11 walkthrough +citations. Rebuilding twice produces identical tour bytes, preserves the prior +Stop/Retry description, and leaves the unaffected execution tour unchanged. diff --git a/docs/START_HERE.md b/docs/START_HERE.md index 0bfdd2f..2fa9010 100644 --- a/docs/START_HERE.md +++ b/docs/START_HERE.md @@ -135,8 +135,9 @@ actually start. **Output** — a user message appended to the thread; assistant-ui then calls the adapter in Step 4. **Failure behavior** — while a response runs, Send gives way to Stop response. -Stop ends its display updates and preserves completed work; it does not undo -already computed or external work. Incomplete responses offer explicit Retry +Stop cancels pending waits and prevents later response frames and graph updates, +while preserving completed work; it does not undo already computed or external +work. Incomplete responses offer explicit Retry through the existing runtime. Retry is never automatic; this local demo does not certify provider cancellation. **Next** — Step 4 receives the message. @@ -246,12 +247,16 @@ The four names are `collect_context`, `search_synthesize`, const ContextToolUI = makeAssistantToolUI<{ focus: string }, ContextToolResult>({ toolName: "collect_context", -// invocation — src/features/node-agent/runtime/nodeAgentChatAdapter.ts:96-101 +// invocation — src/features/node-agent/runtime/nodeAgentChatAdapter.ts:114-122 parts.set(id, { type: "tool-call", toolCallId: id, toolName, args }); -yield snapshot(); // card appears, "working…" +yield snapshot(); await tick(360); parts.set(id, { type: "tool-call", toolCallId: id, toolName, args, result: toolResult }); -yield snapshot(); // same card, now filled in +yield snapshot(); +await tick(160); +// Check where shared state changes, with no asynchronous gap before the write. +abortSignal.throwIfAborted(); +recordGraph(); ``` Note what this is *not*: there is no tool-calling model, no schema registry, and @@ -262,9 +267,13 @@ embedding NodeAgent supplies real tools through the `ToolRuntime` port in **Input** — the thread's messages, plus an `abortSignal`. **Output** — a stream of message snapshots, each replacing the last. -**Failure behavior** — the abort signal is checked between steps -(`if (abortSignal.aborted) return`). There is no `try/catch`: a throw inside this -generator surfaces as an unhandled rejection, which is the gap Step 8 names. +**Failure behavior** — Stop response rejects a pending delay and releases its +timer and abort listener. The adapter checks cancellation before computing a +response, before each snapshot, and immediately before the shared step writes +to the graph. The original abort reason unwinds the whole generator; the +installed assistant-ui runtime catches `AbortError` and marks the response +cancelled. Other adapter errors become incomplete responses with explicit +Retry, as Step 8 describes. **Next** — Step 7 is what the run writes to. --- @@ -356,8 +365,9 @@ function safe(step: AgentStep, fn: () => T, fallback: T): T { - **Adapter failures are visible.** The existing assistant-ui runtime marks the response incomplete; the thread presents a constant failure message, marks unfinished tool cards failed, and offers explicit Retry. Earlier results stay. -- **Stop response ends display updates.** It preserves completed work and cannot - undo computation already performed by the local adapter or external work. +- **Stop response ends future frames and graph updates.** It cancels pending + waits, preserves completed work, and cannot undo computation already + performed by the local adapter or external work. Retry replaces the current response through the native runtime, runs only on an explicit action, and rejects duplicate immediate activation. - **Rendering exceptions remain separate.** There is still no React render-error diff --git a/scripts/build-tours.mjs b/scripts/build-tours.mjs index b9fa7cd..ddae88b 100644 --- a/scripts/build-tours.mjs +++ b/scripts/build-tours.mjs @@ -29,7 +29,7 @@ const tours = [ ["src/app/main.tsx", "createRoot(el).render", "Mount one component. That is the entire job of the entry file."], ["src/features/node-agent/components/NodeAgentDemoApp.tsx", "const runtime = useLocalRuntime", "The one decision that says HOW the agent runs. `useLocalRuntime` comes from the installed @assistant-ui/react package and gives us a working chat runtime in exchange for one adapter object. Swapping this adapter for a server-backed one is the single edit that takes this demo to production."], ["src/features/node-agent/components/NodeAgentDemoApp.tsx", "", "Renders nothing visible. It exists to register the four inline tool cards with the runtime — see tour step 6."], - ["src/features/node-agent/components/NodeAgentThread.tsx", "", "The loop's whole failure story. A throwing step is recorded as an error and given a fallback so later steps still run and the user still gets a memo that admits the gap. The loop never throws."], - ["src/features/node-agent/runtime/nodeAgentChatAdapter.ts", "if (abortSignal.aborted) return", "The abort signal is checked BETWEEN steps only — because the loop already finished synchronously before this generator started yielding. Nothing in the UI is wired to trigger it (defect D3)."], + ["src/features/node-agent/runtime/nodeAgentChatAdapter.ts", "recordGraph();", "When an analyst presses Stop response, the shared step checks cancellation immediately before this synchronous graph write. Pending waits reject and every snapshot checks before emission, so later graph updates and response frames stop together. The installed assistant-ui runtime catches AbortError and reports cancelled. The domain loop already ran synchronously; stopping does not undo its computation or earlier completed work."], ["src/features/node-agent/components/NodeAgentDemoApp.tsx", "", "GAP: no component in this tree is a React error boundary, and the adapter has no catch. A throw in any child unmounts the whole app to a blank page. That is exactly how defect D1 presented. A fix starts on this line."], ["src/app/styles.css", "@media (max-width: 960px)", "The site of defect D1. This block used to say `.na-rail { display: none }`. CSS owned visibility, React owned mounting and gated on data — so a WebGL renderer was mounted into a 0x0 box, threw, and took the app down. The rail is now a bottom panel instead: one owner, no hidden-but-mounted state."], ["e2e/capture-journey-at-width.mjs", "graphMounted:", "The browser gate asserts the graph canvas is not zero-width — the CAUSE of D1, not its symptom. Re-hide the rail by any mechanism and this turns red."],