[Bug] OpenAI Responses streaming: tool-call-args-delta/tool-call-ready never emitted (callBuffers keyed by call_id, but events carry item_id) — MCPAgent.stream() yields zero steps
Describe the bug
On the OpenAI Responses streaming path, streamResponsesTurn keys its tool-call buffers (callBuffers) by call_id, but the streaming events that carry the arguments — response.function_call_arguments.delta and response.function_call_arguments.done — do not have a call_id field; they carry item_id (the output-item id, fc_...) and output_index only. So the buffer lookup key is always the fallback "" and callBuffers.get("") is always undefined. Neither tool-call-args-delta nor tool-call-ready is ever emitted.
Consequence: streamNativeAgentSteps (native_runner.ts) builds AgentSteps only on tool-call-ready, so MCPAgent.stream() / agent.streamEvents() silently yields zero steps for provider "openai" (and openrouter models routed to OpenAIResponsesDriver). The tool still executes (arguments are parsed from response.completed's output), so the bug is silent: the user sees the final text but no tool step at all.
Environment
- mcp-use agent package version:
2.0.16(libraries/typescript/packages/agent/package.json) - commit:
dcaa0b8df5721f11d4d2aefae1f85df2a1903ba5 - Node:
v22.23.2 - OS: macOS 26.6.2 (Darwin 25.6.0), headless Node (no browser involved)
openaiSDK:6.17.0(lockfile-matched)
To Reproduce
Steps:
- Check out
dcaa0b8df5721f11d4d2aefae1f85df2a1903ba5. - Run the repro below against
src/llmwithtsx.
The repro feeds raw OpenAI Responses SSE events with the real SDK event shapes (SSE stream served by a stubbed globalThis.fetch), first asserting on driver.stream() event types, then on streamNativeAgentSteps.
import { OpenAIResponsesDriver } from "../src/llm/providers/openai-responses-driver.ts";
import { streamNativeAgentSteps } from "../src/llm/native_runner.ts";
import type { LlmStreamEvent } from "../src/llm/types.ts";
// Real OpenAI Responses streaming event shapes, taken from the openai-node SDK
// (ResponseFunctionCallArgumentsDeltaEvent / ...DoneEvent carry item_id +
// output_index, NOT call_id).
const turn1 = [
{ type: "response.output_item.added", output_index: 0,
item: { type: "function_call", id: "fc_1", call_id: "call_abc", name: "add", arguments: "" } },
{ type: "response.function_call_arguments.delta", item_id: "fc_1", output_index: 0, delta: '{"a":10' },
{ type: "response.function_call_arguments.delta", item_id: "fc_1", output_index: 0, delta: ',"b":20}' },
{ type: "response.function_call_arguments.done", item_id: "fc_1", output_index: 0, arguments: '{"a":10,"b":20}' },
{ type: "response.completed", response: { output: [
{ type: "function_call", id: "fc_1", call_id: "call_abc", name: "add", arguments: '{"a":10,"b":20}' } ] } },
];
const turn2 = [
{ type: "response.output_text.delta", delta: "The answer is 30." },
{ type: "response.completed", response: { output: [
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "The answer is 30." }] } ] } },
];
const sse = (events: unknown[]) => events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");
let call = 0;
(globalThis as any).fetch = async () => {
call++;
return new Response(sse(call === 1 ? turn1 : turn2), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
};
async function main() {
const driver = new OpenAIResponsesDriver({ provider: "openai", model: "gpt-5", apiKey: "k" });
let toolArgSeen: unknown = null;
const events: LlmStreamEvent[] = [];
for await (const ev of driver.stream({ messages: [{ role: "user", content: "add 10 and 20" }], tools: [] })) {
events.push(ev);
}
console.log("driver.stream() event types:", JSON.stringify(events.map((e) => e.type)));
console.log("tool-call-ready count:", events.filter((e) => e.type === "tool-call-ready").length);
console.log("args-delta count:", events.filter((e) => e.type === "tool-call-args-delta").length);
call = 0;
const steps: unknown[] = [];
const gen = streamNativeAgentSteps(driver, {
messages: [{ role: "user", content: "add 10 and 20" }],
tools: [],
maxSteps: 3,
callTool: async (_n, args) => {
toolArgSeen = args;
return { content: [{ type: "text", text: "30" }] };
},
});
let r = await gen.next();
while (!r.done) { steps.push(r.value); r = await gen.next(); }
console.log("callTool received args:", JSON.stringify(toolArgSeen));
console.log("agent.stream() steps yielded:", steps.length, JSON.stringify(steps));
console.log("agent.stream() returned text:", JSON.stringify(r.value));
}
main();Actual output
Verbatim from an independent verifier run (node tsx repro2.ts, unmodified repro above):
driver.stream() event types: ["tool-call-start","done"]
tool-call-ready count: 0
args-delta count: 0
callTool received args: {"a":10,"b":20}
agent.stream() steps yielded: 0 []
agent.stream() returned text: "The answer is 30."A second independent verifier, running the same shape and an identical probe against the tree src/:
SHAPE A (spec: item_id only): types ["tool-call-start","done"]; args-delta 0; tool-call-ready 0
SHAPE A: callTool got {"name":"add","args":{"a":10,"b":20}}
SHAPE A: agent steps 0 []; final text "The answer is 30."
SHAPE B (control: call_id injected): args-delta 2; tool-call-ready 1
PATCHED, SHAPE A: types ["tool-call-start","tool-call-args-delta","tool-call-args-delta","tool-call-ready","done"]; args-delta 2; tool-call-ready 1
PATCHED, SHAPE A: agent steps 2; baseline vitest run src/llm 47/47 pass, patched also 47/47Expected behavior
The OpenAI Responses path should emit tool-call-args-delta for each response.function_call_arguments.delta and one tool-call-ready when response.function_call_arguments.done arrives, matching:
- the openai-node SDK event contract —
ResponseFunctionCallArgumentsDeltaEvent={ delta, item_id, output_index, sequence_number, type }andResponseFunctionCallArgumentsDoneEvent={ arguments, item_id, output_index, sequence_number, type }([email protected],resources/responses/responses.d.ts; raw.githubusercontent.com/openai/openai-node/src/resources/responses/responses.ts lines 3257 / 3287). Neither declarescall_id.ResponseFunctionToolCalldeclarescall_id: stringandid?: string— distinct fields;item_idpoints at the output-itemid(fc_...). - the sibling provider
providers/openai-chat-completions.ts, which emitstool-call-args-delta(line 205) andtool-call-ready(line 226). - the internal contract
llm/types.ts:157—LlmToolCallReadyEventis documented as "A tool call with complete, parsed arguments", andnative_runner.ts:51-58builds an AgentStep only ontool-call-ready.
So agent.stream() should yield one AgentStep for the add call, instead of 0 [].
Root cause
libraries/typescript/packages/agent/src/llm/providers/openai-responses.ts:328 and :345: both handlers read const callId = typeof parsed.call_id === "string" ? parsed.call_id : "", but these events carry item_id, not call_id, so the key is always "" and callBuffers.get(callId) (line 330 / line 348) is always undefined. The buffer is written under item.call_id at line 311 (populated from response.output_item.added, line 308). Result: the delta and done branches never emit their events, and every downstream consumer of tool-call-ready sees an empty step list.
Fix direction (not a full diff): key callBuffers by the output item id (item.id, i.e. fc_...) and look it up with parsed.item_id (or fall back via output_index to the stored index) in the delta/done handlers, while still yielding the stored call_id as toolCallId on the emitted events.
Screenshots
N/A — headless Node library, no UI screenshot applies.
Desktop (please complete the following information)
- OS: macOS 26.6.2
- Version: mcp-use agent
2.0.16@dcaa0b8df5721f11d4d2aefae1f85df2a1903ba5, Node v22.23.2, openai 6.17.0 - Browser: N/A
Smartphone (please complete the following information)
N/A — not a mobile issue.
Additional context
- Reachability:
new MCPAgent({ llm: "openai/gpt-5", ... }).stream({ prompt })→agents/mcp_agent.ts:549streamNativeAgentSteps(driver)→llm/native_runner.ts:52→createLlmDriverreturnsOpenAIResponsesDriver(llm/driver.ts:53-62) →streamToolLoop→streamResponsesTurn. Same path for openrouter models matching/^~?openai\//. The inspector client-side chat (useChatMessagesClientSide.ts:436) creates the tool card attool-call-startwith args{}and never receivestool-call-ready. - Related issues/PRs found by collision checks: none. Searches for
tool-call-ready,function_call_arguments, and "Responses streaming" in mcp-use/mcp-use return only unrelated closed issues. - Happy to open a PR with the approach sketched above if you'd like, or happy to be assigned.
Source: mcp-use/mcp-use