When a language model calls a tool, it does not hand you the call in one piece. It streams events: first that a tool named search is starting, then the argument JSON a few characters at a time, then a signal that the arguments are complete, and finally — after your code runs the tool — a result. Several tool calls can stream at once, interleaved, each tagged with its own id. Your job is to track them: route each event to the right call by id, accumulate its argument text, and move it through a small state machine.
Implement toolCallStreamingStateMachine(). It returns a controller with apply(event), get(id), and list(). Each tool call is { id, name, argsText, args, status, result, error } and moves through the states pending (arguments still streaming) → running (arguments complete, tool executing) → done (result in), with error as an escape hatch.
The events:
start { id, name } — announce a new call; it begins pending.delta { id, argsTextDelta } — append a fragment to that call's argsText.ready { id } — arguments are complete; parse argsText and move to running.result { id, result } — the tool returned; move to done.error { id, error } — the call failed.type ToolCall = { id: string; name: string; argsText: string; args?: unknown; status: 'pending' | 'running' | 'done' | 'error'; result?: unknown; error?: unknown };
function toolCallStreamingStateMachine(): {
apply(event: { type: string; id: string; name?: string; argsTextDelta?: string; result?: unknown; error?: unknown }): void;
get(id: string): ToolCall | undefined;
list(): ToolCall[];
};
const m = toolCallStreamingStateMachine();
m.apply({ type: 'start', id: 't1', name: 'search' });
m.apply({ type: 'delta', id: 't1', argsTextDelta: '{"q":' });
m.apply({ type: 'delta', id: 't1', argsTextDelta: '"hi"}' });
m.apply({ type: 'ready', id: 't1' });
m.get('t1'); // { id:'t1', name:'search', argsText:'{"q":"hi"}', args:{ q:'hi' }, status:'running' }
// Out-of-order events are ignored, not fatal.
m.apply({ type: 'start', id: 't2', name: 'now' });
m.apply({ type: 'result', id: 't2', result: 42 }); // too early — still pending
m.get('t2').status; // 'pending'
id; use it to find the right call. Concurrent calls must not mix.argsTextDelta as raw text while pending; only JSON.parse on ready.delta after ready, a result while pending, or an error after done should be ignored.argsText is not valid JSON on ready, the call becomes error.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.