1jehuang / 1jehuang/jcode

[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)

Open
#1,336 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area: providers autonomous: clear bug priority: high regression triage: reproducible
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), model copilot: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

  1. 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
    
  2. Send a minimal tool task: run the bash tool with echo HELLO-TOOLS-85, then reply with just that line.
  3. 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-276tool_call_state() inserts with calls.entry(item_id…); the id comes from streaming_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.addeditem.id = <random A>, item.call_id = call_…, name = bash, output_index = 0
  • 11 × response.function_call_arguments.deltaitem_id = <random B…L> (different each event, no call_id), output_index = 0
  • response.function_call_arguments.doneitem_id = <random M>, output_index = 0
  • response.output_item.doneitem.id = <random N>, call_id = call_…, full arguments, 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:

  1. output_item.added creates state#1 (started=true, lowest order) and immediately emits ToolUseStart (:332-343).
  2. Each delta creates its own unnamed state (:451-468); the selection filter in stream_tool_calls (:325-327) excludes those (started=false, name=None).
  3. The selection rule min_by_key((!started, order)) (:328) therefore always picks state#1, which never becomes complete, so :356-358 breaks and ToolUseEnd (:359) is never emitted.
  4. output_item.done for a tool item takes the early-return branch (:487-510) and never reaches the self-contained snapshot rebuild in handle_openai_output_item (:594, tool branch :613-646) — that fallback is now unreachable for function_call / custom_tool_call items.

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:

  1. Parse output_index and resolve the state key with the precedence output_indexitem_id (merging into an already-tracked state whose call_id matches, when the item id is new) → call_id. output_index is the only identity present on every event of a call, so it is the natural key.
  2. Keep the incremental behaviour from 436b6a73a (early ToolUseStart, streamed fragments) and let the output_item.done snapshot flow into the already-started state, so the existing "emitted length" bookkeeping releases only the unseen suffix and then ToolUseEnd. That restores "item.done finishes the call" for streams that never send arguments.done, without emitting a second ToolUseStart.
  3. 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 (two output_index values) and custom_tool_call_input.*.

Measured locally on master 79bc8fbe1 (throwaway worktree, tests only): the captured-stream replay goes 2 failed0 failed, and cargo test -p jcode-provider-openai --lib goes 19 passed; 6 failed25 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 keep call_id primary and infer "the active call" for delta events (which carry no call_id)? We avoided the inference because it mis-attributes interleaved parallel calls.
  • Should the item.done snapshot 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.