From 802fd43faa9054c31ae4bc7bbae55bb2544eae1f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 10:31:44 -0700 Subject: [PATCH 1/2] fix(ag-ui): accumulate TOOL_CALL_ARGS deltas before parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live model streams tool-call args as many partial-JSON fragments; the reducer parsed each delta in isolation (only ever valid when the whole payload arrives in one chunk, e.g. aimock fixtures), so args silently stayed {} and tool views rendered empty — stuck in their loading state. Found by a live-LLM local smoke of the client-tools weather card. Accumulate the raw text per toolCallId, parse the accumulated buffer, keep last-good args, finalize on TOOL_CALL_END. Regression tests cover fragmented, single-chunk, and interleaved streams. Co-Authored-By: Claude Opus 4.8 (1M context) --- libs/ag-ui/src/lib/reducer.spec.ts | 37 +++++++++++++++++++++++ libs/ag-ui/src/lib/reducer.ts | 47 ++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/libs/ag-ui/src/lib/reducer.spec.ts b/libs/ag-ui/src/lib/reducer.spec.ts index edf0ba05d..087005249 100644 --- a/libs/ag-ui/src/lib/reducer.spec.ts +++ b/libs/ag-ui/src/lib/reducer.spec.ts @@ -101,6 +101,43 @@ describe('reduceEvent', () => { expect(store.toolCalls()[0].args).toEqual({ q: 'hi' }); }); + it('TOOL_CALL_ARGS accumulates streamed fragments into the full args', () => { + // Regression: a live model streams args as many partial-JSON deltas + // (`{"loca`, `tion":"Pa`, …). Parsing each delta in isolation never + // succeeds, so args silently stayed {} and tool views rendered empty + // (stuck on their loading state). The reducer must accumulate the raw + // text and parse the accumulated buffer. + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'weather_card' } as any, store); + for (const delta of ['{"loca', 'tion":"Par', 'is","temperatureF":7', '2}']) { + reduceEvent({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta } as any, store); + } + reduceEvent({ type: 'TOOL_CALL_END', toolCallId: 't1' } as any, store); + expect(store.toolCalls()[0].args).toEqual({ location: 'Paris', temperatureF: 72 }); + expect(store.toolCalls()[0].status).toBe('complete'); + }); + + it('TOOL_CALL_ARGS keeps last-good args while the buffer is mid-fragment', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); + reduceEvent({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"q":"hi"}' } as any, store); + expect(store.toolCalls()[0].args).toEqual({ q: 'hi' }); + }); + + it('TOOL_CALL_ARGS buffers independently per tool call', () => { + const store = makeStore(); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'a' } as any, store); + reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't2', toolCallName: 'b' } as any, store); + reduceEvent({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"x":' } as any, store); + reduceEvent({ type: 'TOOL_CALL_ARGS', toolCallId: 't2', delta: '{"y":' } as any, store); + reduceEvent({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '1}' } as any, store); + reduceEvent({ type: 'TOOL_CALL_ARGS', toolCallId: 't2', delta: '2}' } as any, store); + reduceEvent({ type: 'TOOL_CALL_END', toolCallId: 't1' } as any, store); + reduceEvent({ type: 'TOOL_CALL_END', toolCallId: 't2' } as any, store); + expect(store.toolCalls().find((t) => t.id === 't1')!.args).toEqual({ x: 1 }); + expect(store.toolCalls().find((t) => t.id === 't2')!.args).toEqual({ y: 2 }); + }); + it('TOOL_CALL_END marks the matching tool call complete', () => { const store = makeStore(); reduceEvent({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' } as any, store); diff --git a/libs/ag-ui/src/lib/reducer.ts b/libs/ag-ui/src/lib/reducer.ts index 7cc2b354d..74f4459a3 100644 --- a/libs/ag-ui/src/lib/reducer.ts +++ b/libs/ag-ui/src/lib/reducer.ts @@ -53,6 +53,13 @@ export interface ReducerStore { interrupt: WritableSignal; events$: Subject; customEvents: WritableSignal; + /** Accumulated raw TOOL_CALL_ARGS text per toolCallId. A live model streams + * args as many partial-JSON fragments, so each delta must be appended here + * and the ACCUMULATED buffer parsed — parsing a lone delta only succeeds + * when the whole payload happens to arrive in one chunk (e.g. test + * fixtures). Lazily created by the reducer; entries dropped on + * TOOL_CALL_END. */ + argsBuffers?: Map; } /** @@ -190,16 +197,35 @@ export function reduceEvent(event: BaseEvent, store: ReducerStore): void { } case 'TOOL_CALL_ARGS': { const e = event as unknown as { toolCallId: string; delta: string }; - const args = safeParseArgs(e.delta); - store.toolCalls.update((prev) => - prev.map((t) => t.id === e.toolCallId ? { ...t, args } : t), - ); + // Deltas are FRAGMENTS of a JSON document, not standalone JSON: a live + // model streams args token-by-token (`{"loca`, `tion":"Pa`, …), so we + // accumulate the raw text and parse the accumulated buffer. Until the + // buffer parses, keep the last-good args (initially {}). + const buffers = (store.argsBuffers ??= new Map()); + const buffer = (buffers.get(e.toolCallId) ?? '') + e.delta; + buffers.set(e.toolCallId, buffer); + const args = tryParseArgs(buffer); + if (args !== undefined) { + store.toolCalls.update((prev) => + prev.map((t) => t.id === e.toolCallId ? { ...t, args } : t), + ); + } return; } case 'TOOL_CALL_END': { const e = event as unknown as { toolCallId: string }; + // Belt and braces: apply the final accumulated args (in case the last + // ARGS delta arrived but an intermediate state was left unparsed), then + // drop the buffer. + const finalBuffer = store.argsBuffers?.get(e.toolCallId); + store.argsBuffers?.delete(e.toolCallId); + const finalArgs = finalBuffer !== undefined ? tryParseArgs(finalBuffer) : undefined; store.toolCalls.update((prev) => - prev.map((t) => t.id === e.toolCallId ? { ...t, status: 'complete' } : t), + prev.map((t) => + t.id === e.toolCallId + ? { ...t, status: 'complete', ...(finalArgs !== undefined ? { args: finalArgs } : {}) } + : t, + ), ); return; } @@ -313,6 +339,17 @@ function safeParseArgs(delta: string): Record { } } +/** Parse an (accumulated) args buffer; `undefined` when it isn't valid JSON + * yet — callers keep the previous args rather than clobbering them with {}. */ +function tryParseArgs(buffer: string): Record | undefined { + try { + const parsed = JSON.parse(buffer); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + /** Parse a JSON string to its value; return the original string on failure. */ function safeParseJson(s: string): unknown { try { From 668cb71221713853ac7f9e74357e151f251c2112 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 11 Jun 2026 10:36:50 -0700 Subject: [PATCH 2/2] test(deploy): pin ag-ui exclusion in shared LangGraph config generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvaged from #651 (superseded by #652, which landed the fix without a test): the generator must never include ag-ui capabilities — they are Railway- deployed and ship no langgraph.json; including them crashed every LangGraph deploy for days. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../generate-shared-deployment-config.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scripts/generate-shared-deployment-config.spec.ts b/scripts/generate-shared-deployment-config.spec.ts index 0e9c0d08c..50a085d95 100644 --- a/scripts/generate-shared-deployment-config.spec.ts +++ b/scripts/generate-shared-deployment-config.spec.ts @@ -21,4 +21,21 @@ describe('generate-shared-deployment-config', () => { expect(manifest.graphs.chat).toMatch(/examples-chat\/.+\.py:graph$/); expect(manifest.dependencies.some((d) => d.includes('examples-chat'))).toBe(true); }); + + it('excludes ag-ui capabilities (Railway-deployed; no langgraph.json)', () => { + // Regression: ag-ui capabilities gained a pythonDir when they got real + // uvicorn backends, which made the generator try to read a langgraph.json + // they don't have — crashing every LangGraph deploy. They must be skipped + // (they deploy via scripts/generate-ag-ui-deployment-config.ts → Railway). + const root = resolve(__dirname, '..'); + execSync('npx tsx scripts/generate-shared-deployment-config.ts', { + cwd: root, + stdio: 'pipe', + }); + const manifestPath = resolve(root, 'deployments/shared-dev/langgraph.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + dependencies: string[]; + }; + expect(manifest.dependencies.some((d) => d.includes('ag-ui'))).toBe(false); + }); });