Skip to content

opencode-go/muse-spark-1.2: frequent 'upstream stream ended mid tool call without a terminal signal — possible truncation' in streaming tool calls (Codex App via ocx, Pi direct OK) #2156

Closed

Description

@fede-oss

Client or integration

Codex App (also reproduced via Pi coding-agent 0.83.0 when routed through OpenCodex proxy)

Area

Provider adapter

Summary

muse-spark-1.2 via opencode-go (adapter: openai-chat, baseUrl: https://opencode.ai/zen/go/v1) fails frequently in streaming tool-call turns with:

upstream stream ended mid tool call without a terminal signal — possible truncation

Source is src/adapters/openai-chat.ts:1718-1726:

const sawFinish = finishReason !== undefined;
if (!sawFinish && pendingToolCalls.length > 0) {
  yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
}

The upstream SSE emits response.created / tool deltas but closes without finish_reason, [DONE], or response.completed. The model is reasoning-heavy and the turn is truncated mid-JSON, leaving pending tool calls un-finalized. Same model via Pi direct to opencode-go (no ocx) is fine — this isolates the failure to the ocx streaming proxy layer, matching anomalyco/opencode#40888 matrix (stream:true + tools fails, stream:false + tools works).

Expected: streaming tool request should produce a complete terminal sequence or a structured retryable error, or automatically fallback to buffered mode like the opencode#40888 workaround (stream:false + reframe as SSE).

Reproduction

  1. ocx start --port 10100 (default, opencodex 2.26.0)
  2. Pi/Codex config:
    {
      "defaultProvider": "opencode-go",
      "defaultModel": "muse-spark-1.2-contributor",
      "defaultThinkingLevel": "xhigh"
    }
  3. Run a session that triggers a tool-heavy turn (e.g., read/write/edit batch, ~14k thinking tokens). No manual abort.
  4. Observe request log: turn emits thinking then 1-3 tool calls, last tool args truncated mid-token (cd ~/code/zosmaai/z-like), then proxy yields:
    [openai-chat] stream-truncated { finishReason: null, hadUsage: false, pendingToolCalls: 2 }
    error: upstream stream ended mid tool call without a terminal signal — possible truncation
    
  5. Reproduction matrix (same as anomalyco/opencode#40888 for gpt-5.6-luna through same opencode Go endpoint):
    • stream:true + tools → fails with upstream stream ended mid tool call...
    • stream:false + tools → works (buffered, reframed as SSE)
    • stream:true + no tools → works
  6. Frequency via Codex App → ocx → opencode-go/muse-spark-1.2: ~30-50% of tool turns with xhigh. Pi direct (no ocx) not observed. Other models via same proxy (opencode-go/kimi-k2.7-code, qwen3.8-max) rarely hit it.

Why this model hits it a lot:

  • Reasoning budget vs output cap: muse-spark-1.2 defaults to extended thinking (budgetTokens: min(16000, limit.output/2)). opencode Go caps OUTPUT_TOKEN_MAX=32000 (see opencode#18108). Turns that spend 13-16k thinking + a 30-40KB write JSON hit finishReason: length mid-tool-call. See opencode#18108 repros: reasoning ~130k, output 32000, finish: "length", text_len=0.
  • Known bridge shape: zosmaai/zosma-cowork#329 (mimo-v2.5 via opencode-go → 3 bash pending running) and opencode#26170 (finishReason: "other" with 0 output) show same orphaned-running-tool pattern behind opencode-go.

Version

2.26.0

Operating system

macOS 15.7.9 (Build 24G830), Darwin 24.6.0 arm64

Provider and model

opencode-go / muse-spark-1.2 (Pi alias muse-spark-1.2-contributor, PI_REASONING_LEVEL=xhigh, adapter openai-chat, baseUrl: https://opencode.ai/zen/go/v1, authMode: key)

Logs or error output

[openai-chat] stream-truncated { finishReason: null, hadUsage: false, pendingToolCalls: 2 }
error: upstream stream ended mid tool call without a terminal signal — possible truncation

No finish_reason, no [DONE], 2 pending tool calls (last JSON truncated mid-key). Same as opencode#40888 observed response.created then HTTP 502 / stream termination during tool call.

Screenshots and supporting files

No screenshots — error is in proxy log / SSE stream. Can provide curl SSE capture or ocx --debug trace on request.

Redacted configuration

{
  "port": 10100,
  "providers": {
    "opencode-go": {
      "adapter": "openai-chat",
      "baseUrl": "https://opencode.ai/zen/go/v1",
      "authMode": "key"
    }
  }
}

Catalog entry (~/.opencodex/catalog-backup.json):

{ "slug": "muse-spark-1.2", "provider": "opencode-go" }

Related upstream reports (same error family):

  • anomalyco/opencode#40888 — exact same string, stream:false workaround works
  • anomalyco/opencode#18108 — finishReason: length doom loop, OUTPUT_TOKEN_MAX=32000
  • anomalyco/opencode#26170 / #26177 / #13102 — truncated tool variants
  • zosmaai/zosma-cowork#329 — mimo-v2.5 via opencode-go orphaned running
  • can1357/oh-my-pi#5600 / earendil-works/pi#4433 / pi#6019 / pi#6133

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. coderabbitai commented on Aug 19, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #2155 - fix(openai-chat): tolerate non-string padding repeats on resolved stream tool calls [open]
    #2127 - fix(google): preserve and replay thought signatures across stream chunks and multi-tool turns (#2125) [open]
    #2112 - fix(catalog): allow per-provider/model opt-out of code_mode_only tool mode (#2106) [merged]
    #2145 - fix(responses): bridge routed tool_search through the Responses passthrough [open]
    #2150 - fix(google): preserve and replay thought signatures across stream chunks [open]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. added
    cliCLI, config inject, packaging flags
    streamingSSE, WebSocket, terminal stream frames
    toolstool_calls, MCP, web-search / sidecar tools
    on Aug 19, 2026
  3. github-actions commented on Aug 19, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  4. added
    bugSomething isn't working
    providerProvider adapters, OpenAI-compat presets, upstream API quirks
    on Aug 19, 2026
  5. Ingwannu commented on Aug 20, 2026

    @Ingwannu
    Owner

    I verified the exact current parser path. The reported message is an intentional fail-closed guard: it is emitted only after the upstream body reaches EOF while at least one tool call is still buffered and no Chat finish_reason / [DONE] terminal was observed. OpenCodex must not flush the partial JSON as an executable tool call.

    The current evidence establishes a real failed turn, but it does not yet establish which component owns the truncation. response.created is not a Chat Completions terminal, while this provider/model currently uses the openai-chat adapter. PR #2155 is separate: it handles malformed non-string padding on continuation deltas and does not repair a genuine EOF with partial tool arguments.

    Before adding a model-scoped stream:false compatibility path, please attach one redacted raw upstream capture from the exact OpenCodex request, including:

    • the final 10 SSE events/frames before disconnect,
    • the exact upstream path and the model, stream, and tool-related request fields,
    • whether any Chat finish_reason, [DONE], Responses response.completed / response.incomplete, or structured error frame appears,
    • and the terminal shape from the same request with stream:false.

    If the socket truly closes mid-JSON, this is an upstream streaming defect and the safe OpenCodex workaround is a narrowly evidence-backed buffered bridge for the affected provider/model. If a terminal frame exists in a different wire shape, the correct fix is in the parser instead. I am keeping this open as needs-info rather than guessing between those two incompatible fixes.

  6. added
    needs-infoWaiting on reporter for a concrete spec or reproduction
    on Aug 20, 2026
  7. lidge-jun commented on Aug 20, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 68 / 80

    실화임. 지금 openai-chat.ts 1727줄이 finishReason 없고 pendingToolCalls 있으면 바로 upstream stream ended mid tool call without a terminal signal — possible truncation 냄. muse-spark-1.2가 opencode.ai/zen/go/v1에서 툴 델타만 주고 스트림을 닫음. finish_reason/[DONE]/response.completed 없음. args가 cd ~/code/zosmaai/z처럼 잘림. Pi direct는 됨. ocx 프록시만. stream:true + tools만 죽음. 2.26.0에서 30-50%.

    #2155 패딩이랑 다른 구멍임. 그건 non-string 반복. 이건 종단 시그널. 같이 넣어도 이 400은 안 죽음. 어댑터가 중간 JSON을 에러로 올려서 클라이언트가 재시도/버퍼 폴백을 못 함. OpenCode #40888이 stream:false로 우회한 바로 그 매트릭스임.

    2.27은 이미 나갔음. 2.28 후보임. types.ts/config.ts 안 건드리는 버그임. 분할이랑 무관.

    점수는 68임. 실사용 툴턴이 반타작임. #2137 bearer보다 체감은 이쪽일 수 있음. 드래프트 #2155를 이걸로 닫지 말 것.

    해결방안: 게이트웨이가 툴+스트림에서 자주 끊기면 그 프로바이더만 버퍼 폴백 (stream:false 후 SSE 재포장). 아니면 잘린 args를 에러 대신 재시도 가능하게. 전역으로 finish 없는 툴콜을 성공 처리하면 #1531 fail-closed가 무너짐. 재현은 opencode-go + muse-spark-1.2 + xhigh 툴 배치.

    이 댓글은 grok-bot이 작성했습니다

  8. Vontean commented on Aug 20, 2026

    @Vontean

    Independent reproduction (opencodex 2.24.0, Codex App -> ocx -> opencode-go, muse-spark-1.2-contributor @ xhigh): 22/22 tool turns failed today, 502/503, same error string.

    Additional raw-wire evidence against https://opencode.ai/zen/go/v1 (from our setup):

    • BOTH muse-spark-1.2 and muse-spark-1.2-contributor streaming SSE never sends [DONE] and never emits a real finish_reason (only null), with or without stream_options.include_usage and with or without reasoning_effort. The stream just ends after a usage chunk and a trailing {"choices":[],"cost":"0"} frame.
    • Forcing tool_choice on the streamed path returns an empty assistant message with finish_reason:null (no tool_calls delta at all).
    • The BUFFERED path is well-formed: stream:false + tools returns complete tool_calls JSON (verified).
    • Sibling model deepseek-v4-flash on the SAME endpoint streams correctly (finish_reason:stop + [DONE]) -> the missing-terminal defect is scoped to the Muse backend relay behind the zen/go gateway, not the gateway as a whole.
    • The standard OpenAI-compatible base /zen/v1 rejects these models (Model not supported), so /zen/go/v1 is the only accessible route for Muse today.

    Status of latest: v2.27.0 still contains the identical fail-closed guard (src/adapters/openai-chat.ts:1734) and adds no tolerance/buffered-fallback option for openai-chat routed models. So this remains unfixed upstream.

    Local workaround we validated A/B (not a PR, just reporting the direction in case it helps the fix): for an allowlisted model, force stream:false upstream and reframe the buffered JSON as SSE via the existing parseResponse (mirrors the anomalyco/opencode#40888 stream:false workaround). Without the allowlist knob the exact error reproduces; with it the same request completes with response.completed + a full function_call on 200.

  9. Vontean commented on Aug 20, 2026

    @Vontean

    Independent reproduction (opencodex 2.24.0, Codex App -> ocx -> opencode-go, muse-spark-1.2-contributor @ xhigh): 22/22 tool turns failed today, 502/503, same error string.

    Additional raw-wire evidence against https://opencode.ai/zen/go/v1 (from our setup):

    • BOTH muse-spark-1.2 and muse-spark-1.2-contributor streaming SSE never sends [DONE] and never emits a real finish_reason (only null), with or without stream_options.include_usage and with or without reasoning_effort. The stream just ends after a usage chunk and a trailing {"choices":[],"cost":"0"} frame.
    • Forcing tool_choice on the streamed path returns an empty assistant message with finish_reason:null (no tool_calls delta at all).
    • The BUFFERED path is well-formed: stream:false + tools returns complete tool_calls JSON (verified).
    • Sibling model deepseek-v4-flash on the SAME endpoint streams correctly (finish_reason:stop + [DONE]) -> the missing-terminal defect is scoped to the Muse backend relay behind the zen/go gateway, not the gateway as a whole.
    • The standard OpenAI-compatible base /zen/v1 rejects these models (Model not supported), so /zen/go/v1 is the only accessible route for Muse today.

    Status of latest: v2.27.0 still contains the identical fail-closed guard (src/adapters/openai-chat.ts:1734) and adds no tolerance/buffered-fallback option for openai-chat routed models. So this remains unfixed upstream.

    Local workaround we validated A/B (not a PR, just reporting the direction in case it helps the fix): for an allowlisted model, force stream:false upstream and reframe the buffered JSON as SSE via the existing parseResponse (mirrors the anomalyco/opencode#40888 stream:false workaround). Without the allowlist knob the exact error reproduces; with it the same request completes with response.completed + a full function_call on 200.

  10. lidge-jun commented on Aug 20, 2026

    @lidge-jun
    Owner

    Traced this end to end. Two separate things came out of it, and I want to keep them apart rather than let one stand in for the other.

    One real defect on our side, fixed in #2180. Tool-call deltas are buffered until a terminal signal, so openai-chat can consume upstream frames for a long time while yielding nothing downstream. The Responses bridge arms its stall watchdog on adapter activity, not socket activity — so a model streaming a large argument payload looks exactly like a hung upstream, and the turn can be aborted while it is progressing normally. That is ours, it is real independently of this issue, and it now emits an invisible heartbeat the way the Cursor, Anthropic, Google, and Kiro adapters already do.

    But it is probably not what you are seeing. The watchdog's own failure surfaces as response.incomplete with upstream_stall_timeout, not as the adapter error in your title — and it only fires after stallTimeoutSec (300s by default). Unless your tool-call phase is running that long, #2180 will not change your symptom.

    The guard itself is behaving correctly. The message fires only when the stream reaches EOF with no finish_reason ever observed, no [DONE], and at least one tool call still buffered. I checked whether we are stricter than the wire warrants, and we are not:

    • tool deltas + finish_reason: "tool_calls" — accepted
    • tool deltas + [DONE], no finish_reason — accepted
    • a finish chunk with no [DONE] — accepted
    • a usage-only chunk after the finish chunk — accepted
    • no same-chunk or ordering requirement at all

    So the shape you described — complete arguments, usage frame, a trailing {"choices":[],"cost":"0"}, then EOF with only finish_reason: null — is a stream that never terminated. Accepting it globally would mean executing tool calls whose arguments may have been cut off, and JSON that happens to parse at a truncation boundary is not evidence the call is complete.

    What would settle it. Everything you have posted describes the wire; what is still missing is the wire itself. One artifact decides this:

    A redacted raw SSE capture from replaying opencodex's exact outbound request body against /zen/go/v1/chat/completions — including the outbound model, stream, stream_options, and the reasoning/tool fields — plus the final ten literal SSE frames through socket close.

    The outbound body matters as much as the frames. "Pi direct works" does not yet isolate us, because Pi may send different tool schemas, omit stream_options: {include_usage: true} (we add it to every streamed request), or map reasoning fields differently. If the capture shows Muse closing without a terminal signal on a request shaped like ours, the answer is a model-scoped buffered bridge for opencode-go Muse 1.2 — mirroring the forced-buffering policy that already exists for other models — and not a change to the global guard.

    Keeping needs-info for that one capture. Thanks for the detail so far, and for the sibling-model control in particular — that ruled out a whole branch.

  11. lidge-jun commented on Aug 20, 2026

    @lidge-jun
    Owner

    Thanks for the detailed report, @fede-oss — the source pointer and the stream-truncated line saved a lot of guessing.

    I read the detection code against your evidence, and I want to be straight about what I found rather than ship something adjacent and call it a fix.

    What I did find and fix (but it is not your bug). Tool-call deltas are buffered in openai-chat until a terminal signal, so the adapter can consume upstream frames for a long time while yielding nothing. The Responses bridge arms its stall watchdog on adapter activity, not socket activity — so a model streaming a large argument payload looks identical to a hung upstream. That is a real false-stall hazard and #2180 fixes it with a heartbeat.

    It is not what you hit. A stall abort emits response.incomplete with incomplete_details.reason = "upstream_stall_timeout", and the bridge has already closed by then. Your log shows the adapter's own end-of-stream error — reader.read() returned EOF with two tool calls still pending. Different path, different frame. So I have deliberately not marked #2180 as fixing this issue.

    Which leaves the actual question open, and it is a sharp one: your stream really did end. The adapter is reporting truthfully. What I cannot tell from here is why it ended for ocx and not for Pi direct — that could be a request-shape difference, transport behaviour, or the upstream closing on us specifically.

    Your hadUsage: false is suggestive but not decisive: ocx does send stream_options.include_usage = true, so a missing usage chunk is consistent with the upstream disappearing early — but an OpenAI-compatible provider is also free to ignore that flag.

    The one thing that would settle it:

    Redacted raw SSE captures for the same prompt, tool schema, model, and request options from (1) a successful Pi-direct request and (2) a failing ocx request — including timestamps and the final frames right through socket close. Specifically: did either stream contain finish_reason, data: [DONE], or a usage-only final chunk?

    If the ocx capture shows the upstream closing without a terminal frame while the Pi-direct capture gets one for an equivalent request, the difference is in what we send or how we read it, and that is squarely ours to fix. If both close the same way and Pi is simply more tolerant, then the right fix is a buffered-mode fallback rather than a stricter or looser detector — which is what you suggested, and I would rather choose between those on your capture than on my guess.

    ocx debug provider on then ocx debug provider logs -f will give you the ocx side. Keeping the needs-info label on for that reason.

  12. Ingwannu commented on Aug 20, 2026

    @Ingwannu
    Owner

    Additional evidence from duplicate #2194 strengthens this report without changing the current safety boundary:

    • OpenCodex 2.27.0 on macOS reproduces the same stream-truncated diagnostic with finishReason: null, hadUsage: false, and one pending tool call.
    • The same large CodexBar sweep fails for Muse Spark at low, medium, high, and xhigh effort, while DeepSeek V4 Flash and GPT-5.6 Luna controls complete successfully.
    • Small Muse Spark prompts still succeed, so this is scoped to large streaming tool-call turns rather than general provider admission.
    • The client-side 5/5 sequence is Codex reconnecting after the same 502; it is not an OpenCodex recovery loop.

    This still does not justify flushing the buffered tool call at EOF: the arguments may be incomplete, and treating them as executable would turn a transport failure into an unsafe successful tool call. The remaining decisive evidence is a redacted raw SSE tail from the affected OpenCode Go request, ideally the final ten literal frames plus whether [DONE], finish_reason, or another terminal event was present. A comparable Pi-direct capture would establish whether the provider emits a different stream for that client.

    I am closing #2194 as a duplicate and keeping this issue as the canonical investigation.

  13. lidge-jun commented on Aug 25, 2026

    @lidge-jun
    Owner

    Fixed on dev. Two commits, both verified as ancestors of the current head.

    The actionable mismatch was routing: muse-spark-1.2-contributor was being read as a Chat stream when the gateway serves Responses. 1a15d6292 routes that exact model through openai-responses, which avoids the malformed Chat framing entirely. As you may have seen, #2617 found a second wire quirk on the same model — its gateway rejects search_content_types on web_search — so this model genuinely diverges from its siblings.

    Separately, 08cc2ac89 handles terminal-less OpenCode Go streams: some gateways close right after a complete function-call delta and omit both terminal conventions. A complete tool-call JSON payload is now recovered for opted-in providers.

    One thing deliberately left fail-closed: an incomplete JSON prefix still takes the truncation path and surfaces an explicit error. OpenCodex does not truncate the call itself — the Chat adapter consumes the body through EOF — so when the stream really did end mid call, the honest outcome is a named failure rather than a silently half-formed tool call that then executes.

    Scope note: bare muse-spark-1.2 is intentionally not in the exact-model Responses allowlist, because the endpoint evidence only establishes the -contributor variant. If you are hitting this on the bare id, say so and I will extend it — I would rather ask than guess a routing change for a model I have no evidence about.

    Covering run: 54 pass / 0 fail across the Muse compat, OpenCode Go wire, and Chat EOF suites.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcliCLI, config inject, packaging flagsneeds-infoWaiting on reporter for a concrete spec or reproductionproviderProvider adapters, OpenAI-compat presets, upstream API quirksstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions