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
37 changes: 37 additions & 0 deletions libs/ag-ui/src/lib/reducer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
47 changes: 42 additions & 5 deletions libs/ag-ui/src/lib/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export interface ReducerStore {
interrupt: WritableSignal<AgentInterrupt | undefined>;
events$: Subject<AgentEvent>;
customEvents: WritableSignal<CustomStreamEvent[]>;
/** 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<string, string>;
}

/**
Expand Down Expand Up @@ -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<string, string>());
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;
}
Expand Down Expand Up @@ -313,6 +339,17 @@ function safeParseArgs(delta: string): Record<string, unknown> {
}
}

/** 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<string, unknown> | 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 {
Expand Down
17 changes: 17 additions & 0 deletions scripts/generate-shared-deployment-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading