Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .tours/01-primary-user-flow.tour
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand All @@ -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)."
},
Expand All @@ -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."
}
Expand Down
8 changes: 4 additions & 4 deletions .tours/03-debug-and-recovery.tour
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions CHANGELOG/runtime/node-agent-chat-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 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.

### 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.
30 changes: 20 additions & 10 deletions docs/START_HERE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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.

---
Expand Down Expand Up @@ -356,8 +365,9 @@ function safe<T>(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
Expand Down
6 changes: 3 additions & 3 deletions scripts/build-tours.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", "<NodeAgentToolUIs />", "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", "<ComposerPrimitive.Send", "The control the user actually presses. Note it is the only button on the page while a run is in flight, and it is disabled — there is no cancel. That is open defect D3."],
["src/features/node-agent/components/NodeAgentThread.tsx", "<ComposerPrimitive.Send", "Send starts a response. While it runs, Stop response ends its display updates and preserves completed work; it does not undo already computed or external work. An incomplete response offers explicit Retry through the existing runtime, never automatic retry. This local demo does not certify provider cancellation."],
["src/features/node-agent/runtime/nodeAgentChatAdapter.ts", "async *run({ messages, abortSignal })", "Pressing Enter lands here. This is the bridge between the chat UI and the agent."],
["src/features/node-agent/runtime/nodeAgentChatAdapter.ts", "const result = runNodeAgent(scenario)", "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."],
["src/features/node-agent/runtime/nodeAgentRuntime.ts", "export function runNodeAgent", "The loop itself. Four numbered blocks, top to bottom, no framework. If you read one function in this repo, read this one."],
Expand Down Expand Up @@ -65,10 +65,10 @@ const tours = [
file: "03-debug-and-recovery.tour",
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.",
"Failure handling, cancellation boundaries, the remaining render-error gap, and how to make every check in this repo go red on purpose.",
steps: [
["src/features/node-agent/runtime/nodeAgentRuntime.ts", "function safe<T>", "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", "<AssistantRuntimeProvider runtime={runtime}>", "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."],
Expand Down
52 changes: 36 additions & 16 deletions src/features/node-agent/runtime/nodeAgentChatAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -71,35 +72,54 @@ export const nodeAgentChatAdapter: ChatModelAdapter = {
}

const reduced = prefersReducedMotion();
const tick = (ms: number) =>
new Promise<void>((res) => setTimeout(res, reduced ? 0 : ms));
const tick = async (ms: number) => {
abortSignal.throwIfAborted();
await new Promise<void>((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<string, Record<string, unknown>>();
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(
id: string,
toolName: string,
args: Record<string, unknown>,
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();
Expand All @@ -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");
Expand Down
Loading
Loading