[Bug] OpenAI /responses stream loses tool calls when every event carries a different item_id — Copilot gpt-5.6-luna turns come back empty (v0.85.0)
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 19.9k
- Forks
- 2.3k
- Avg merge
- 2d 7h
- Merged PRs (30d)
- 30
Description
Problem
On the Copilot provider with gpt-5.6-luna (routed to /responses), jcode v0.85.0 parses the first tool call of a turn into a bare ToolUseStart and then loses both the argument fragments and the ToolUseEnd. The agent side only materializes a tool call when ToolUseEnd arrives (crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs:601), so the turn ends with no text and no tool calls at all — the agent then injects The previous provider response was empty after tool results … up to five times and gives up (stopReason=end_turn, empty text). Any tool-driven task is impossible on that path. The same prompt on v0.84.0 works.
Environment
- jcode v0.85.0 (official release binary; also reproduced with a self-built v0.85.0), Linux x86_64. The same code is present at master tip
79bc8fbe1. - Provider
copilot(GitHub Copilot), modelcopilot:gpt-5.6-luna;gpt-5.6*is routed to/responses(crates/jcode-provider-copilot-runtime/src/lib.rs:66-76) and consumed as SSE (:693). - Controls: v0.85.0 +
claude-sonnet-5(chat/completions) works; v0.84.0 + luna works.
Reproduction
- Start a Copilot-only daemon (a shared daemon cannot carry this model):
JCODE_RUNTIME_DIR=/tmp/jc-luna-rt jcode serve --socket /tmp/jcode-luna.sock \ -p copilot -m copilot:gpt-5.6-luna --no-update - Send a minimal tool task: run the
bashtool withecho HELLO-TOOLS-85, then reply with just that line. - v0.85.0 → repeated empty-response reminders, no tool execution, empty final text. v0.84.0 → the tool runs and the answer is printed.
The provider stream itself is healthy: the SSE stream completes (response.completed), output tokens are accounted, and the transport reports a normal end-of-message.
What the parser emits
Replaying the captured /responses SSE for that turn through the crate's own stream type (OpenAIResponsesStream — the same parser process_responses_sse_stream consumes) yields exactly three events:
ToolUseStart { id: "call_…", name: "bash" }
TokenUsage { … }
MessageEnd { stop_reason: None }
One start, zero ToolInputDelta, zero ToolUseEnd: the arguments arrive from the server but never reach the consumer.
Root cause
Tool-call state is keyed by item_id:
crates/jcode-provider-openai/src/stream.rs:265-276—tool_call_state()inserts withcalls.entry(item_id…); the id comes fromstreaming_tool_item_id()(:258).
But this /responses stream sends a fresh random item_id on every event. The identities that are stable for one call are call_id (only on item events) and output_index (on every event). For a single 11-fragment call the capture looks like:
response.output_item.added→item.id = <random A>,item.call_id = call_…,name = bash,output_index = 0- 11 ×
response.function_call_arguments.delta→item_id = <random B…L>(different each event, nocall_id),output_index = 0 response.function_call_arguments.done→item_id = <random M>,output_index = 0response.output_item.done→item.id = <random N>,call_id = call_…, fullarguments,output_index = 0
ResponseSseEvent does not parse output_index at all, and call_id is stored on the state but never used for association, so every event opens its own state entry:
output_item.addedcreates state#1 (started=true, lowestorder) and immediately emitsToolUseStart(:332-343).- Each delta creates its own unnamed state (
:451-468); the selection filter instream_tool_calls(:325-327) excludes those (started=false,name=None). - The selection rule
min_by_key((!started, order))(:328) therefore always picks state#1, which never becomescomplete, so:356-358breaks andToolUseEnd(:359) is never emitted. output_item.donefor a tool item takes the early-return branch (:487-510) and never reaches the self-contained snapshot rebuild inhandle_openai_output_item(:594, tool branch:613-646) — that fallback is now unreachable forfunction_call/custom_tool_callitems.
Result: arguments and ToolUseEnd are permanently lost, and completed_tool_items never matches any real id.
This regressed in 436b6a73a ("fix(openai): stream tool names and inputs before arguments complete"), which introduced the incremental state machine (early ToolUseStart, streamed argument fragments) and, in the same change, moved output_item.done for tool items out of the snapshot path. v0.84.0 had no state-machine emission and rebuilt each call from the self-contained output_item.done snapshot, which is immune to unstable ids.
Why the current tests don't catch it
crates/jcode-provider-openai/src/stream_tool_tests.rs builds every event with the same id (added(tx, "a", …), delta(tx, "a", …), done(tx, "a", …); the delta/done helpers do not even carry call_id). No case uses the real shape (fresh id per event, stable call_id / output_index), so the crate suite stays green (19 passed) while the real stream fails.
Proposed approach (for discussion)
Happy to send a PR if the direction looks right — a starting point, not a frozen spec. Single file, no public signature changes:
- Parse
output_indexand resolve the state key with the precedenceoutput_index→item_id(merging into an already-tracked state whosecall_idmatches, when the item id is new) →call_id.output_indexis the only identity present on every event of a call, so it is the natural key. - Keep the incremental behaviour from
436b6a73a(earlyToolUseStart, streamed fragments) and let theoutput_item.donesnapshot flow into the already-started state, so the existing "emitted length" bookkeeping releases only the unseen suffix and thenToolUseEnd. That restores "item.donefinishes the call" for streams that never sendarguments.done, without emitting a secondToolUseStart. - Tests: replay a real captured stream (fresh id per event, stable
call_id/output_index), plus a stable-id control, an item.done-only case, interleaved parallel calls (twooutput_indexvalues) andcustom_tool_call_input.*.
Measured locally on master 79bc8fbe1 (throwaway worktree, tests only): the captured-stream replay goes 2 failed → 0 failed, and cargo test -p jcode-provider-openai --lib goes 19 passed; 6 failed → 25 passed; 0 failed, with the nine existing stream_tool_tests cases untouched and green.
Two questions:
- Is
output_index-first keying acceptable, or would you rather keepcall_idprimary and infer "the active call" for delta events (which carry nocall_id)? We avoided the inference because it mis-attributes interleaved parallel calls. - Should the
item.donesnapshot be allowed to complete an in-flight call unconditionally, or would you prefer it gated on something else?
Workaround
Stay on v0.84.0 for the Copilot gpt-5.6* (/responses) path; chat/completions-based Copilot models are unaffected. Happy to attach the full captured SSE (event ids shortened above) if useful.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in crates/jcode-provider-openai/src/stream.rs, tracing OpenAIResponsesStream through process_responses_sse_stream and the tool-call state handling. Read crates/jcode-provider-openai/src/stream_tool_tests.rs, then run cargo test -p jcode-provider-openai --lib with a replay matching the reported event identities. Done means argument fragments and ToolUseEnd survive the captured stream, while existing and parallel-call cases remain passing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- api, testing
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100