From 17cf097ef336d4f86bc208ee1639190c17ef1f1e Mon Sep 17 00:00:00 2001 From: kentaro Date: Sun, 16 Aug 2026 21:33:46 +0200 Subject: [PATCH 1/4] fix(agent): surface interrupted local tool calls on every terminal path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal announce for an incomplete local tool call was gated on `declinedRecoveryForExposedReasoning`, so the buffered `tool-input-start` was only flushed when the step happened to carry a persisted reasoning block. `inputAnnounced` stays false for exactly this class of call, so `emitSse: toolCall.inputAnnounced === true` suppressed the `tool-output-error` on every other terminal path — maxSteps exhaustion, a second interruption in the same run, and an exposed local sibling or provider-executed call. The stream ended with no text, no tool call and no error while history recorded a failure. Pass `announceInput: true` unconditionally in the `!shouldContinue` loop. Whether recovery was declined for reasoning, a spent step budget or an exposed sibling is invisible to the user, so it must not decide whether the failure renders. `recordIncompleteLocalToolError` still guards on `providerExecuted`, incompleteness and a final result, so only genuinely truncated local calls are announced, and `announceStreamedToolCallInput` is idempotent for anything already surfaced upstream. Exactly one failure event per call still reaches the wire. The `tool-input-error` emission at index.ts:~2332 is gated on the same `inputAnnounced` flag and runs before this loop, so it stays dead on this path; all four affected tests pin `tool-input-error` at 0 alongside a `tool-output-error` count of 1. Issue point 1 (bare `{}` placeholders): they now surface on the terminal path. `preserveRecoverablePlaceholderToolCalls` already keeps such a call in the assistant message and `recordIncompleteLocalToolError` already writes a matching tool-result error and an errored `toolCalls` entry, so withholding it from the wire made persisted history and the live stream disagree about the same run. A placeholder that is still recoverable is unaffected: it is re-asked rather than terminalized, so it never enters this loop. Two existing assertions in refresh.test.ts changed: - "fails closed after a local sibling was exposed, with or without a final result": `tool-output-error` count 0 -> 1. Failing closed means not re-running the batch, not hiding the truncation. The exposed sibling is complete, so it never enters the incomplete branch and contributes no error; the single error belongs to `truncated-agent-after-exposure`, which the same step already writes into history. Tightened to assert the toolCallId on both the announce and the error, plus `tool-input-error` 0. - "recovers a placeholder after assistant text only once": `tool-output-error` count 0 -> 1. The test already asserts that the step-2 placeholder `toolu_repeated_placeholder` is kept in the assistant message with a tool-result error and an errored entry in `toolCalls` — that is the history/wire disagreement above, in the same test. The step-1 placeholder `toolu_placeholder_after_text` is recovered rather than terminalized, and a new assertion pins its announce count at 0 so the once-only recovery guarantee is still covered. Added "surfaces a truncated local tool call when maxSteps exhaustion ends the run", which fails on the pre-fix runtime with 0 announces for the truncated call. Closes #3737 --- src/agent/runtime/index.ts | 21 ++-- src/agent/runtime/refresh.test.ts | 157 +++++++++++++++++++++++++++++- 2 files changed, 170 insertions(+), 8 deletions(-) diff --git a/src/agent/runtime/index.ts b/src/agent/runtime/index.ts index 5ce0bf06d2..8b708bb3c2 100644 --- a/src/agent/runtime/index.ts +++ b/src/agent/runtime/index.ts @@ -2648,9 +2648,13 @@ export class AgentRuntime { if (options.announceInput === true) { // An interrupted call never reached `tool-input-end`, so its // `tool-input-start` is still buffered and `inputAnnounced` is false - // — which would suppress the `tool-output-error` below. On the - // declined-recovery path that leaves the client with a reasoning - // block and then nothing at all. + // — which would suppress the `tool-output-error` below. Every + // terminal path passes `announceInput`, because on all of them the + // run stops here and the client would otherwise be left with + // whatever preceded the truncation and then nothing at all. Which + // path declined recovery — exposed reasoning, a spent step budget, a + // second interruption, an exposed sibling — is invisible to the + // user, so it must not decide whether the failure renders (#3737). // // The name is safe to publish here. `tool-call` is what can supersede // a name, and it also sets `inputAvailable`, which fails the guard @@ -2689,9 +2693,14 @@ export class AgentRuntime { await persistToolResult(toolResult); } for (const toolCall of streamedToolCalls) { - await recordIncompleteLocalToolError(toolCall, { - announceInput: declinedRecoveryForExposedReasoning, - }); + // Terminal. Every incomplete local call recorded here is also + // terminalized into history, so announce unconditionally and let the + // wire carry the same failure. `recordIncompleteLocalToolError` + // guards on `providerExecuted`, completeness and a final result, so + // only genuinely truncated local calls are announced, and + // `announceStreamedToolCallInput` is idempotent for any already + // surfaced upstream. + await recordIncompleteLocalToolError(toolCall, { announceInput: true }); } sendSSE(controller, encoder, { type: "step-end" }); break; diff --git a/src/agent/runtime/refresh.test.ts b/src/agent/runtime/refresh.test.ts index ab13dfa1cd..520e6a9543 100644 --- a/src/agent/runtime/refresh.test.ts +++ b/src/agent/runtime/refresh.test.ts @@ -2053,11 +2053,140 @@ describe("agent runtime refresh hooks", () => { body.match(/"type":"tool-output-available"/g)?.length ?? 0, hasFinalResult ? 1 : 0, ); - assertEquals(body.match(/"type":"tool-output-error"/g)?.length ?? 0, 0); + // #3737. Failing closed is about not re-running the batch, not about + // hiding the truncation. The exposed sibling rendered, but the + // interrupted call is terminalized into history here, so it also has to + // reach the wire — one announce and one failure for it, and none for the + // exposed sibling, which is complete and so never enters that branch. + assertEquals( + body.match( + /"type":"tool-input-start","toolCallId":"truncated-agent-after-exposure"/g, + )?.length ?? 0, + 1, + ); + assertEquals( + body.match( + /"type":"tool-output-error","toolCallId":"truncated-agent-after-exposure"/g, + )?.length ?? 0, + 1, + ); + assertEquals(body.match(/"type":"tool-output-error"/g)?.length ?? 0, 1); + assertEquals(body.match(/"type":"tool-input-error"/g)?.length ?? 0, 0); assertEquals(body.includes("Unexpected recovery."), false); } }); + it("surfaces a truncated local tool call when maxSteps exhaustion ends the run", async () => { + // #3737. Recovery is declined here because the step budget is spent, not + // because reasoning was exposed, so the flush #3735 added never runs. The + // run terminalizes the truncated call into history either way, so the wire + // has to carry the same failure — otherwise the stream ends with no text, + // no tool call and no error. + let finishedResponse: AgentResponse | undefined; + let callCount = 0; + const model: ModelRuntime = { + provider: "hosted", + modelId: "hosted/truncated-at-max-steps", + async doGenerate() { + return { + content: [{ type: "text", text: "unused" }], + finishReason: "stop", + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }; + }, + async doStream() { + callCount++; + if (callCount === 1) { + return { + stream: createRuntimeStream([ + { + type: "tool-call", + toolCallId: "issue3737-committed", + toolName: "issue3737_probe", + input: '{"revision":"first"}', + }, + { + type: "finish", + finishReason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 1 }, + }, + ]), + }; + } + return { + stream: createRuntimeStream([ + { + type: "tool-input-start", + id: "issue3737-truncated", + toolName: "issue3737_probe", + }, + { + type: "tool-input-delta", + id: "issue3737-truncated", + delta: '{"revision":"trunc', + }, + { + type: "finish", + finishReason: "tool-calls", + usage: { inputTokens: 1, outputTokens: 1 }, + }, + ]), + }; + }, + }; + const probeTool = tool({ + id: "issue3737_probe", + description: "Apply a revision", + inputSchema: defineSchema((v) => v.object({ revision: v.string() }))(), + execute: async ({ revision }) => ({ revision }), + }); + const assistant = eagerAgent({ + model: "hosted/truncated-at-max-steps", + system: "Exhaust the step budget on a truncated tool call.", + tools: { issue3737_probe: probeTool }, + maxSteps: 2, + resolveModelTransport: async () => ({ model }), + }); + + const body = await (await assistant.stream({ + input: "Apply both revisions", + onFinish: (result) => { + finishedResponse = result; + }, + })).toDataStreamResponse().text(); + + assertEquals(callCount, 2); + // The truncated call must be announced so its failure card has something + // to render against. + assertEquals( + body.match( + /"type":"tool-input-start","toolCallId":"issue3737-truncated"/g, + )?.length ?? 0, + 1, + ); + // Exactly one failure event on the wire for it — never a tool-input-error + // alongside the tool-output-error. + assertEquals( + body.match(/"type":"tool-output-error","toolCallId":"issue3737-truncated"/g)?.length ?? 0, + 1, + ); + assertEquals(body.match(/"type":"tool-input-error"/g)?.length ?? 0, 0); + assertEquals( + body.includes( + 'Stream terminated before tool-call event fired for \\"issue3737_probe\\"', + ), + true, + ); + assertExists(finishedResponse); + assertEquals( + finishedResponse.toolCalls.map((toolCall) => [toolCall.id, toolCall.status]), + [ + ["issue3737-committed", "completed"], + ["issue3737-truncated", "error"], + ], + ); + }); + it("does not report stale text when an ordinary tool-only step exhausts maxSteps", async () => { let callCount = 0; let finishedResponse: AgentResponse | undefined; @@ -2211,7 +2340,31 @@ describe("agent runtime refresh hooks", () => { assertEquals(callCount, 2); assertEquals(toolResults, []); assertEquals(body.match(/Created the Outlook assistant\./g)?.length ?? 0, 1); - assertEquals(body.match(/"type":"tool-output-error"/g)?.length ?? 0, 0); + // #3737. The step-1 placeholder is still off the wire: recovery ran, so it + // is provisional and gets re-asked. The step-2 placeholder is terminal — + // the assertions below show it kept in the assistant message with a + // matching tool-result error and an errored entry in `toolCalls`, so + // holding it back from the wire made the persisted history and the live + // stream disagree. One announce and one failure, for the terminal one only. + assertEquals( + body.match( + /"type":"tool-input-start","toolCallId":"toolu_repeated_placeholder"/g, + )?.length ?? 0, + 1, + ); + assertEquals( + body.match( + /"type":"tool-input-start","toolCallId":"toolu_placeholder_after_text"/g, + )?.length ?? 0, + 0, + ); + assertEquals( + body.match(/"type":"tool-output-error","toolCallId":"toolu_repeated_placeholder"/g)?.length ?? + 0, + 1, + ); + assertEquals(body.match(/"type":"tool-output-error"/g)?.length ?? 0, 1); + assertEquals(body.match(/"type":"tool-input-error"/g)?.length ?? 0, 0); assertEquals(body.includes("Unexpected second recovery."), false); const completedResponse = finishedResponse as AgentResponse | undefined; assertExists(completedResponse); From df704fc65c49324a7349e458db1fb5f2e68e7647 Mon Sep 17 00:00:00 2001 From: kentaro Date: Sun, 16 Aug 2026 21:48:12 +0200 Subject: [PATCH 2/4] fix(ag-ui): close an open tool input before its output error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review finding on #3782. A truncated local tool call terminalizes as `tool-input-start` (plus any partial deltas) and then straight to `tool-output-error`. In the browser encoder, `tool-input-available` and `tool-input-error` both close the input through `completeToolInput`, which emits `ToolCallEnd`; the `tool-output-error` branch only emitted `ToolCallResult`. AG-UI clients were therefore left with ToolCallStart, ToolCallArgs and ToolCallResult and no ToolCallEnd, holding the tool-input lifecycle open. This predates #3782 — the declined-reasoning path #3735 added already produced it — but widening the announce to every terminal path takes it from one rare case to all of them, so it is fixed here rather than filed. Fixed in the encoder rather than by emitting an extra runtime event, so the lifecycle closes for any producer of `tool-output-error`, not just this path. `openToolCallIds` tracks calls whose `ToolCallStart` has been emitted and not yet closed; it is distinct from `streamedToolInputIds`, which records whether args were streamed rather than whether the call is open. No synthetic args are emitted on this path. `completeToolInput` back-fills a `ToolCallArgs` when none streamed, but here the model never committed any input, and writing `{}` would claim it did. Red: "closes an open tool input before emitting its output error" fails with ToolCallResult alone. A second test pins that a call already closed by `tool-input-available` does not get a duplicate ToolCallEnd — that one passes before and after, and exists to stop the fix over-reaching. Green: browser-encoder 3 passed (24 steps); full ag-ui suite 29 passed (157 steps); runtime refresh.test.ts 2 passed (56 steps). --- src/agent/ag-ui/browser-encoder.test.ts | 54 +++++++++++++++++++++++++ src/agent/ag-ui/browser-encoder.ts | 34 ++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/src/agent/ag-ui/browser-encoder.test.ts b/src/agent/ag-ui/browser-encoder.test.ts index 7c878e2e42..5e0c5d642c 100644 --- a/src/agent/ag-ui/browser-encoder.test.ts +++ b/src/agent/ag-ui/browser-encoder.test.ts @@ -976,3 +976,57 @@ describe("buildAgUiBrowserFinalizeResponse", () => { }]); }); }); + +describe("agent/ag-ui-browser-encoder tool-input lifecycle", () => { + // Raised in review on #3737. A truncated local tool call terminalizes as + // `tool-input-start` (+ any partial deltas) followed by `tool-output-error`. + // Unlike `tool-input-available` and `tool-input-error`, the output-error + // branch never closed the input, so AG-UI clients saw ToolCallStart and + // ToolCallResult with no ToolCallEnd and an input lifecycle left open. + it("closes an open tool input before emitting its output error", () => { + const state = createAgUiBrowserEncoderState({ nowMs: null, epochMs: null }); + + mapRuntimeStreamEventToAgUiBrowserEvents(state, { + type: "tool-input-start", + toolCallId: "truncated-1", + toolName: "search", + }); + const events = mapRuntimeStreamEventToAgUiBrowserEvents(state, { + type: "tool-output-error", + toolCallId: "truncated-1", + errorText: "interrupted", + }); + + const names = events.map((entry) => entry.event); + assertEquals(names, ["ToolCallEnd", "ToolCallResult"]); + assertEquals( + events[0], + { event: "ToolCallEnd", payload: { toolCallId: "truncated-1" } }, + ); + }); + + it("does not close a tool input that was already completed", () => { + const state = createAgUiBrowserEncoderState({ nowMs: null, epochMs: null }); + + mapRuntimeStreamEventToAgUiBrowserEvents(state, { + type: "tool-input-start", + toolCallId: "settled-1", + toolName: "search", + }); + mapRuntimeStreamEventToAgUiBrowserEvents(state, { + type: "tool-input-available", + toolCallId: "settled-1", + toolName: "search", + input: { q: "veryfront" }, + }); + const events = mapRuntimeStreamEventToAgUiBrowserEvents(state, { + type: "tool-output-error", + toolCallId: "settled-1", + errorText: "upstream 500", + }); + + // Exactly one ToolCallEnd per call: the input-available branch already + // emitted it, so a normal tool failure must not emit a second. + assertEquals(events.map((entry) => entry.event), ["ToolCallResult"]); + }); +}); diff --git a/src/agent/ag-ui/browser-encoder.ts b/src/agent/ag-ui/browser-encoder.ts index 3b874b27f3..df5aeb681c 100644 --- a/src/agent/ag-ui/browser-encoder.ts +++ b/src/agent/ag-ui/browser-encoder.ts @@ -46,6 +46,12 @@ export interface AgUiBrowserEncoderState { activeStepName: string | null; stepCount: number; streamedToolInputIds: Set; + /** + * Tool calls whose `ToolCallStart` has been emitted but not yet closed with + * a `ToolCallEnd`. Distinct from `streamedToolInputIds`, which tracks + * whether any args were streamed, not whether the call is still open. + */ + openToolCallIds: Set; sawVisibleOutput: boolean; sawTerminalError: boolean; metadata: AgUiBrowserRunFinishedMetadata; @@ -107,6 +113,7 @@ export function createAgUiBrowserEncoderState( activeStepName: null, stepCount: 0, streamedToolInputIds: new Set(), + openToolCallIds: new Set(), sawVisibleOutput: false, sawTerminalError: false, metadata: {}, @@ -544,6 +551,21 @@ export function buildAgUiBrowserFinalizeResponse( }; } +/** + * Emit the `ToolCallEnd` for a call whose input never reached a terminal + * input event. Returns nothing when the call was already closed, so a normal + * tool failure does not produce a second end. + */ +function closeOpenToolInput( + state: AgUiBrowserEncoderState, + toolCallId: unknown, +): AgUiBrowserEncodedEvent[] { + if (typeof toolCallId !== "string" || toolCallId.length === 0) return []; + if (!state.openToolCallIds.delete(toolCallId)) return []; + state.streamedToolInputIds.delete(toolCallId); + return [{ event: "ToolCallEnd", payload: { toolCallId } }]; +} + function completeToolInput( state: AgUiBrowserEncoderState, event: AgUiRuntimeStreamEvent, @@ -563,6 +585,7 @@ function completeToolInput( if (toolCallId.length > 0) { state.streamedToolInputIds.delete(toolCallId); + state.openToolCallIds.delete(toolCallId); } events.push({ @@ -855,6 +878,9 @@ function mapRuntimeStreamEventToAgUiBrowserEventsUnstamped( ...closeOpenReasoningEvent(state), ]; state.sawVisibleOutput = true; + if (typeof event.toolCallId === "string" && event.toolCallId.length > 0) { + state.openToolCallIds.add(event.toolCallId); + } events.push({ event: "ToolCallStart", payload: { @@ -928,6 +954,14 @@ function mapRuntimeStreamEventToAgUiBrowserEventsUnstamped( return [ ...closeOpenTextEvent(state), ...closeOpenReasoningEvent(state), + // A truncated local tool call terminalizes as `tool-input-start` + // (plus any partial deltas) and then straight to this event, so the + // input is still open. `tool-input-available` and `tool-input-error` + // close it via `completeToolInput`; this branch has to close it too, + // or the client is left with ToolCallStart and ToolCallResult and no + // ToolCallEnd. No synthetic args are emitted: the model never + // committed any, and inventing `{}` would claim it did. + ...closeOpenToolInput(state, event.toolCallId), createToolResultEvent(event.toolCallId, { error: event.errorText }, true), ]; From 15b47289fe3513115946eb8114ceb9df2503b80c Mon Sep 17 00:00:00 2001 From: kentaro Date: Sun, 16 Aug 2026 21:52:59 +0200 Subject: [PATCH 3/4] fix(ag-ui): update the stale ToolCallEnd expectation and regenerate docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consequences of closing the tool input on `tool-output-error`. `src/internal-agents/ag-ui-sse.test.ts` opened `tool-1` with `tool-input-start` and went straight to `tool-output-error`, asserting the result alone. That expectation was encoding the defect: the sequence it described leaves the client holding an open tool-input lifecycle for the rest of the run. It now expects `ToolCallEnd` before `ToolCallResult`, with the reason recorded inline. This is the only stale expectation. Every suite in `src/` that references `tool-output-error` was run together to check — 31 passed, 298 steps, 0 failed — rather than discovering them one CI round at a time. `docs/api-reference/veryfront/agent.md` is regenerated: `openToolCallIds` is a documented field on the exported `AgUiBrowserEncoderState`, so `deno task docs` output moved with it and `ci (lint)` fails on a stale reference. --- docs/api-reference/veryfront/agent.md | 10 +++++----- src/internal-agents/ag-ui-sse.test.ts | 15 +++++++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index b3dff297e2..96763ec1a0 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -547,7 +547,7 @@ Input delivered to a hosted agent-service detached execution callback. | `buildAgentCallContext` | Builds the layered system-message set for one provider call (RFC 0001). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L648) | | `buildAgentDelegateTools` | Builds the opt-in delegate tools for a coordinator agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-delegation.ts#L158) | | `buildAgentRunTraceAttributes` | Builds agent run trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L181) | -| `buildAgUiBrowserFinalizeResponse` | Response payload for build AG-UI browser finalize. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L416) | +| `buildAgUiBrowserFinalizeResponse` | Response payload for build AG-UI browser finalize. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L423) | | `buildAgUiSseTraceSignature` | Build a compact ordered event-type signature for regression checks. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L75) | | `buildChatStreamChunkMessageMetadata` | Builds chat stream chunk message metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/chat-ui-message-helpers.ts#L325) | | `buildChildRunExecutionSnapshot` | Builds child run execution snapshot. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/execution-snapshot.ts#L79) | @@ -634,7 +634,7 @@ Input delivered to a hosted agent-service detached execution callback. | `createAgentServiceRuntime` | Create agent service runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/runtime.ts#L233) | | `createAgentServiceServerRuntime` | Create agent service server runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/server.ts#L54) | | `createAgUiBrowserChunkEncoder` | Create AG-UI browser chunk encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-chunk-encoder.ts#L103) | -| `createAgUiBrowserEncoderState` | State for create AG-UI browser encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L90) | +| `createAgUiBrowserEncoderState` | State for create AG-UI browser encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L96) | | `createAgUiBrowserFinalizeTracker` | Create AG-UI browser finalize tracker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-finalize-tracker.ts#L23) | | `createAgUiBrowserResponseStream` | Create AG-UI browser response stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-response-stream.ts#L68) | | `createAgUiCancelHandler` | Handler for create AG-UI cancel. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/run-control.ts#L148) | @@ -772,7 +772,7 @@ Input delivered to a hosted agent-service detached execution callback. | `fetchLatestConversationUserText` | Fetch latest conversation user text helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-support.ts#L112) | | `filterAgentTraceAttributes` | Filter agent trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L61) | | `filterHostedChatRuntimeLocalTools` | Filter hosted chat runtime local tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L223) | -| `finalizeAgUiBrowserEvents` | Finalize AG-UI browser events helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L984) | +| `finalizeAgUiBrowserEvents` | Finalize AG-UI browser events helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L1018) | | `finalizeChildRunExecutionResources` | Finalize child run execution resources helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/execution-cleanup.ts#L28) | | `finalizeConversationAgentRun` | Finalize conversation agent run helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable.ts#L1326) | | `finalizeHostedChildForkCompletion` | Finalize hosted child fork completion helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-stream-execution.ts#L156) | @@ -865,7 +865,7 @@ Input delivered to a hosted agent-service detached execution callback. | `mapAgUiRuntimeEventToForkParts` | Map AG-UI runtime event to fork parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-part-mapper.ts#L229) | | `mapFrameworkEventToForkParts` | Handles map framework event to fork parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-part-mapper.ts#L408) | | `mapHostedStreamPartToChatUiChunks` | Map hosted stream part to chat UI chunks. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/hosted-ui-chunk-mapping.ts#L220) | -| `mapRuntimeStreamEventToAgUiBrowserEvents` | Map runtime stream event to AG-UI browser events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L681) | +| `mapRuntimeStreamEventToAgUiBrowserEvents` | Map runtime stream event to AG-UI browser events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L704) | | `mergeToolCallInput` | Input payload for merge tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-input.ts#L112) | | `mergeToolInputDelta` | Merge tool input delta helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-input.ts#L54) | | `mirrorDefaultResearchRunArtifact` | Mirror default research run artifact helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-support.ts#L299) | @@ -1181,7 +1181,7 @@ Input delivered to a hosted agent-service detached execution callback. | `AgUiBeforeStreamMessageInput` | Input payload for AG-UI before stream message. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/before-stream.ts#L4) | | `AgUiBeforeStreamResult` | Result returned from AG-UI before stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/before-stream.ts#L25) | | `AgUiBrowserChunkEncoder` | Public API contract for AG-UI browser chunk encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-chunk-encoder.ts#L15) | -| `AgUiBrowserEncodedEvent` | Event emitted for AG-UI browser encoded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L84) | +| `AgUiBrowserEncodedEvent` | Event emitted for AG-UI browser encoded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L90) | | `AgUiBrowserEncoderState` | State for AG-UI browser encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L35) | | `AgUiBrowserFinalizeTracker` | Public API contract for AG-UI browser finalize tracker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-finalize-tracker.ts#L9) | | `AgUiBrowserResponseEncoder` | Public API contract for AG-UI browser response encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-response-stream.ts#L41) | diff --git a/src/internal-agents/ag-ui-sse.test.ts b/src/internal-agents/ag-ui-sse.test.ts index 910c919333..d34358d3bf 100644 --- a/src/internal-agents/ag-ui-sse.test.ts +++ b/src/internal-agents/ag-ui-sse.test.ts @@ -87,10 +87,17 @@ describe("internal-agents/ag-ui-sse", () => { toolCallId: "tool-1", errorText: "boom", }), - [{ - event: "ToolCallResult", - payload: { toolCallId: "tool-1", result: { error: "boom" }, isError: true }, - }], + // `tool-1` opened with `tool-input-start` above and never reached a + // terminal input event, so its input is closed here before the result. + // This expectation previously omitted `ToolCallEnd`, which left the + // client holding an open tool-input lifecycle for the whole run (#3737). + [ + { event: "ToolCallEnd", payload: { toolCallId: "tool-1" } }, + { + event: "ToolCallResult", + payload: { toolCallId: "tool-1", result: { error: "boom" }, isError: true }, + }, + ], ); assertEquals( mapRuntimeEventToAgUi(state, { type: "error", error: "Runtime failed" }), From 4ae6408da7a83edd1dcbb1b89e7ab01fba76bb06 Mon Sep 17 00:00:00 2001 From: kentaro Date: Sun, 16 Aug 2026 22:10:56 +0200 Subject: [PATCH 4/4] fix(ag-ui): make openToolCallIds optional so existing state objects survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on the head commit, and correct. `AgUiBrowserEncoderState` is re-exported from `veryfront/agent` (src/agent/index.ts:752), so a consumer can hold a state object built against the shape the type had before this tracker existed. As a required field, the first `tool-input-start` called `.add` on undefined and crashed. Optional and populated lazily now, which is exactly why `reasoningSpanIndex` directly above it is optional — its comment already says "Optional so a state object built before this counter existed stays valid". `closeOpenToolInput` checks `?.delete(...) !== true` rather than `!...delete()` so an absent set reads as "nothing open" instead of throwing. Covered by a test that deletes the field from a freshly created state and drives the same start/output-error sequence, asserting it still closes the lifecycle rather than crashing. --- docs/api-reference/veryfront/agent.md | 10 +++++----- src/agent/ag-ui/browser-encoder.test.ts | 23 +++++++++++++++++++++++ src/agent/ag-ui/browser-encoder.ts | 14 ++++++++++---- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index 96763ec1a0..a044aa8ac8 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -547,7 +547,7 @@ Input delivered to a hosted agent-service detached execution callback. | `buildAgentCallContext` | Builds the layered system-message set for one provider call (RFC 0001). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/call-context.ts#L648) | | `buildAgentDelegateTools` | Builds the opt-in delegate tools for a coordinator agent. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/agent-delegation.ts#L158) | | `buildAgentRunTraceAttributes` | Builds agent run trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L181) | -| `buildAgUiBrowserFinalizeResponse` | Response payload for build AG-UI browser finalize. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L423) | +| `buildAgUiBrowserFinalizeResponse` | Response payload for build AG-UI browser finalize. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L429) | | `buildAgUiSseTraceSignature` | Build a compact ordered event-type signature for regression checks. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/sse-parser.ts#L75) | | `buildChatStreamChunkMessageMetadata` | Builds chat stream chunk message metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/chat-ui-message-helpers.ts#L325) | | `buildChildRunExecutionSnapshot` | Builds child run execution snapshot. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/execution-snapshot.ts#L79) | @@ -634,7 +634,7 @@ Input delivered to a hosted agent-service detached execution callback. | `createAgentServiceRuntime` | Create agent service runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/runtime.ts#L233) | | `createAgentServiceServerRuntime` | Create agent service server runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/server.ts#L54) | | `createAgUiBrowserChunkEncoder` | Create AG-UI browser chunk encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-chunk-encoder.ts#L103) | -| `createAgUiBrowserEncoderState` | State for create AG-UI browser encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L96) | +| `createAgUiBrowserEncoderState` | State for create AG-UI browser encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L102) | | `createAgUiBrowserFinalizeTracker` | Create AG-UI browser finalize tracker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-finalize-tracker.ts#L23) | | `createAgUiBrowserResponseStream` | Create AG-UI browser response stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-response-stream.ts#L68) | | `createAgUiCancelHandler` | Handler for create AG-UI cancel. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/run-control.ts#L148) | @@ -772,7 +772,7 @@ Input delivered to a hosted agent-service detached execution callback. | `fetchLatestConversationUserText` | Fetch latest conversation user text helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-support.ts#L112) | | `filterAgentTraceAttributes` | Filter agent trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L61) | | `filterHostedChatRuntimeLocalTools` | Filter hosted chat runtime local tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L223) | -| `finalizeAgUiBrowserEvents` | Finalize AG-UI browser events helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L1018) | +| `finalizeAgUiBrowserEvents` | Finalize AG-UI browser events helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L1024) | | `finalizeChildRunExecutionResources` | Finalize child run execution resources helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/execution-cleanup.ts#L28) | | `finalizeConversationAgentRun` | Finalize conversation agent run helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable.ts#L1326) | | `finalizeHostedChildForkCompletion` | Finalize hosted child fork completion helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-stream-execution.ts#L156) | @@ -865,7 +865,7 @@ Input delivered to a hosted agent-service detached execution callback. | `mapAgUiRuntimeEventToForkParts` | Map AG-UI runtime event to fork parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-part-mapper.ts#L229) | | `mapFrameworkEventToForkParts` | Handles map framework event to fork parts. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/fork-runtime-part-mapper.ts#L408) | | `mapHostedStreamPartToChatUiChunks` | Map hosted stream part to chat UI chunks. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/chat/hosted-ui-chunk-mapping.ts#L220) | -| `mapRuntimeStreamEventToAgUiBrowserEvents` | Map runtime stream event to AG-UI browser events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L704) | +| `mapRuntimeStreamEventToAgUiBrowserEvents` | Map runtime stream event to AG-UI browser events. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L710) | | `mergeToolCallInput` | Input payload for merge tool call. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-input.ts#L112) | | `mergeToolInputDelta` | Merge tool input delta helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/tool-input.ts#L54) | | `mirrorDefaultResearchRunArtifact` | Mirror default research run artifact helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-support.ts#L299) | @@ -1181,7 +1181,7 @@ Input delivered to a hosted agent-service detached execution callback. | `AgUiBeforeStreamMessageInput` | Input payload for AG-UI before stream message. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/before-stream.ts#L4) | | `AgUiBeforeStreamResult` | Result returned from AG-UI before stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/before-stream.ts#L25) | | `AgUiBrowserChunkEncoder` | Public API contract for AG-UI browser chunk encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-chunk-encoder.ts#L15) | -| `AgUiBrowserEncodedEvent` | Event emitted for AG-UI browser encoded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L90) | +| `AgUiBrowserEncodedEvent` | Event emitted for AG-UI browser encoded. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L96) | | `AgUiBrowserEncoderState` | State for AG-UI browser encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L35) | | `AgUiBrowserFinalizeTracker` | Public API contract for AG-UI browser finalize tracker. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-finalize-tracker.ts#L9) | | `AgUiBrowserResponseEncoder` | Public API contract for AG-UI browser response encoder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-response-stream.ts#L41) | diff --git a/src/agent/ag-ui/browser-encoder.test.ts b/src/agent/ag-ui/browser-encoder.test.ts index 5e0c5d642c..4b5bd31735 100644 --- a/src/agent/ag-ui/browser-encoder.test.ts +++ b/src/agent/ag-ui/browser-encoder.test.ts @@ -1005,6 +1005,29 @@ describe("agent/ag-ui-browser-encoder tool-input lifecycle", () => { ); }); + it("tolerates a state object built without the tracker", () => { + // `AgUiBrowserEncoderState` is re-exported from `veryfront/agent`, so a + // consumer may hold a state object built against the shape this type had + // before `openToolCallIds` existed. A required field would crash on the + // first `tool-input-start`; `reasoningSpanIndex` is optional for the same + // reason. + const state = createAgUiBrowserEncoderState({ nowMs: null, epochMs: null }); + delete (state as { openToolCallIds?: Set }).openToolCallIds; + + mapRuntimeStreamEventToAgUiBrowserEvents(state, { + type: "tool-input-start", + toolCallId: "legacy-1", + toolName: "search", + }); + const events = mapRuntimeStreamEventToAgUiBrowserEvents(state, { + type: "tool-output-error", + toolCallId: "legacy-1", + errorText: "interrupted", + }); + + assertEquals(events.map((entry) => entry.event), ["ToolCallEnd", "ToolCallResult"]); + }); + it("does not close a tool input that was already completed", () => { const state = createAgUiBrowserEncoderState({ nowMs: null, epochMs: null }); diff --git a/src/agent/ag-ui/browser-encoder.ts b/src/agent/ag-ui/browser-encoder.ts index df5aeb681c..f2f83bb536 100644 --- a/src/agent/ag-ui/browser-encoder.ts +++ b/src/agent/ag-ui/browser-encoder.ts @@ -50,8 +50,14 @@ export interface AgUiBrowserEncoderState { * Tool calls whose `ToolCallStart` has been emitted but not yet closed with * a `ToolCallEnd`. Distinct from `streamedToolInputIds`, which tracks * whether any args were streamed, not whether the call is still open. + * + * Optional, and populated lazily, so a state object built against the shape + * this type had before the tracker existed stays valid — the same reason + * `reasoningSpanIndex` above is optional. This type is re-exported from + * `veryfront/agent`, so a required field would crash existing callers on the + * first `tool-input-start`. */ - openToolCallIds: Set; + openToolCallIds?: Set; sawVisibleOutput: boolean; sawTerminalError: boolean; metadata: AgUiBrowserRunFinishedMetadata; @@ -561,7 +567,7 @@ function closeOpenToolInput( toolCallId: unknown, ): AgUiBrowserEncodedEvent[] { if (typeof toolCallId !== "string" || toolCallId.length === 0) return []; - if (!state.openToolCallIds.delete(toolCallId)) return []; + if (state.openToolCallIds?.delete(toolCallId) !== true) return []; state.streamedToolInputIds.delete(toolCallId); return [{ event: "ToolCallEnd", payload: { toolCallId } }]; } @@ -585,7 +591,7 @@ function completeToolInput( if (toolCallId.length > 0) { state.streamedToolInputIds.delete(toolCallId); - state.openToolCallIds.delete(toolCallId); + state.openToolCallIds?.delete(toolCallId); } events.push({ @@ -879,7 +885,7 @@ function mapRuntimeStreamEventToAgUiBrowserEventsUnstamped( ]; state.sawVisibleOutput = true; if (typeof event.toolCallId === "string" && event.toolCallId.length > 0) { - state.openToolCallIds.add(event.toolCallId); + (state.openToolCallIds ??= new Set()).add(event.toolCallId); } events.push({ event: "ToolCallStart",