Repository navigation
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
Description
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
ocx start --port 10100(default, opencodex 2.26.0)- Pi/Codex config:
{ "defaultProvider": "opencode-go", "defaultModel": "muse-spark-1.2-contributor", "defaultThinkingLevel": "xhigh" } - Run a session that triggers a tool-heavy turn (e.g.,
read/write/editbatch, ~14k thinking tokens). No manual abort. - 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 - Reproduction matrix (same as
anomalyco/opencode#40888forgpt-5.6-lunathrough sameopencode Goendpoint):stream:true + tools→ fails withupstream stream ended mid tool call...stream:false + tools→ works (buffered, reframed as SSE)stream:true + no tools→ works
- Frequency via Codex App →
ocx→opencode-go/muse-spark-1.2: ~30-50% of tool turns withxhigh. Pi direct (noocx) 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.2defaults to extended thinking (budgetTokens: min(16000, limit.output/2)).opencode GocapsOUTPUT_TOKEN_MAX=32000(seeopencode#18108). Turns that spend 13-16k thinking + a 30-40KBwriteJSON hitfinishReason: lengthmid-tool-call. Seeopencode#18108repros:reasoning ~130k, output 32000, finish: "length", text_len=0. - Known bridge shape:
zosmaai/zosma-cowork#329(mimo-v2.5 via opencode-go→ 3bashpendingrunning) andopencode#26170(finishReason: "other" with 0 output) show same orphaned-running-tool pattern behindopencode-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 truncationNo 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:falseworkaround worksanomalyco/opencode#18108—finishReason: lengthdoom loop,OUTPUT_TOKEN_MAX=32000anomalyco/opencode#26170/#26177/#13102— truncated tool variantszosmaai/zosma-cowork#329—mimo-v2.5 via opencode-goorphanedrunningcan1357/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
coderabbitai commentedon Aug 19, 2026
🔗 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 plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.🧪 Issue enrichment is currently in open beta.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:💬 Have feedback or questions? Drop into our discord!
github-actions commentedon Aug 19, 2026
Issue reopened
The report now contains the information required by the automated check. Thanks for updating it.
Ingwannu commentedon Aug 20, 2026
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.createdis not a Chat Completions terminal, while this provider/model currently uses theopenai-chatadapter. 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:falsecompatibility path, please attach one redacted raw upstream capture from the exact OpenCodex request, including:model,stream, and tool-related request fields,finish_reason,[DONE], Responsesresponse.completed/response.incomplete, or structured error frame appears,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-inforather than guessing between those two incompatible fixes.lidge-jun commentedon Aug 20, 2026
리뷰 · 우선순위 68 / 80
실화임. 지금
openai-chat.ts1727줄이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이 작성했습니다
Vontean commentedon Aug 20, 2026
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):
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.
Vontean commentedon Aug 20, 2026
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):
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.
lidge-jun commentedon Aug 20, 2026
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-chatcan 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.incompletewithupstream_stall_timeout, not as the adapter error in your title — and it only fires afterstallTimeoutSec(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_reasonever 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:finish_reason: "tool_calls"— accepted[DONE], nofinish_reason— accepted[DONE]— acceptedSo the shape you described — complete arguments, usage frame, a trailing
{"choices":[],"cost":"0"}, then EOF with onlyfinish_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:
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 foropencode-goMuse 1.2 — mirroring the forced-buffering policy that already exists for other models — and not a change to the global guard.Keeping
needs-infofor that one capture. Thanks for the detail so far, and for the sibling-model control in particular — that ruled out a whole branch.fix(responses): do not retain adapter heartbeats in the terminal guard
docs(openai-chat): stop attributing the heartbeat to #2156
lidge-jun commentedon Aug 20, 2026
Thanks for the detailed report, @fede-oss — the source pointer and the
stream-truncatedline 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-chatuntil 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.incompletewithincomplete_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: falseis suggestive but not decisive: ocx does sendstream_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:
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 onthenocx debug provider logs -fwill give you the ocx side. Keeping theneeds-infolabel on for that reason.fix(openai-chat): heartbeat while buffering tool-call deltas
Ingwannu commentedon Aug 20, 2026
Additional evidence from duplicate #2194 strengthens this report without changing the current safety boundary:
stream-truncateddiagnostic withfinishReason: null,hadUsage: false, and one pending tool call.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.
docs(devlog): record the three-issue round, including the blocked ver…
docs(devlog): record the three-issue round, including the blocked ver…
lidge-jun commentedon Aug 25, 2026
Fixed on
dev. Two commits, both verified as ancestors of the current head.The actionable mismatch was routing:
muse-spark-1.2-contributorwas being read as a Chat stream when the gateway serves Responses.1a15d6292routes that exact model throughopenai-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 rejectssearch_content_typesonweb_search— so this model genuinely diverges from its siblings.Separately,
08cc2ac89handles 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.2is intentionally not in the exact-model Responses allowlist, because the endpoint evidence only establishes the-contributorvariant. 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.
fix(openai-chat): heartbeat while buffering tool-call deltas
fix(responses): do not retain adapter heartbeats in the terminal guard
docs(openai-chat): stop attributing the heartbeat to lidge-jun#2156
docs(devlog): record the three-issue round, including the blocked ver…