Repository navigation
Description
Summary
When a subagent (Task tool) runs against an OpenAI Responses-API model, the assistant's final_answer text is not persisted incrementally. The text part stays empty in the database and the stream never terminates on its own. Only when the user manually aborts does the full answer get flushed (within ~70 ms) and the parent session resume.
This makes subagent calls appear "stuck" while the content is actually already generated and sitting in memory.
Environment
- opencode: observed on v1.18.8 (running binary). Code analysis below is against local source v1.17.8 — line numbers may differ in newer releases, function/file names should still apply.
- Platform: macOS (Darwin)
- Provider / Model:
openai/ Responses-API reasoning models (fast variants reproduce more often) - Runtime:
ai-sdk
Observed behavior (relative timeline, from a real stuck subagent)
- T+0 s: Subagent stream enters the
final_answerphase. Atextpart is created in the DB with empty text andtime.startset (provider metadata carriesphase: final_answer,itemId). - T+0 s → T+~10 min: The part's
textremains empty. No new parts, nostream error, no timeout fired. Thetextpart'stime.endis unset. UI keeps spinning.chunkTimeoutdoes not fire. - T+~10 min (user clicks Abort):
- ~70 ms later the
textpart is populated with the full, complete final answer (time.endset). - ~160 ms later the parent session resumes its loop and continues normally.
- ~70 ms later the
So the content existed all along (in memory) but was only written to the DB when the stream was forcibly terminated.
Expected behavior
- Text deltas should be reflected (in DB / UI) as they arrive.
- If the upstream terminal event is delayed or missing, opencode should finalize once the output is complete, rather than waiting indefinitely for a manual abort.
Likely root cause (based on v1.17.8 source)
Two contributing factors:
1. Incremental text deltas are not persisted to the part table
Session.updatePartDelta only publishes a v2 event and does not write to the DB:
// packages/opencode/src/session/session.ts
const updatePartDelta = Effect.fnUntraced(function* (input) {
yield* events.publish(MessageV2.Event.PartDelta, input) // no DB write
})The text-delta handler accumulates into in-memory ctx.currentText.text:
// packages/opencode/src/session/processor.ts
case "text-delta":
ctx.currentText.text += value.text // memory only
...
yield* session.updatePartDelta(...) // event only, no DB writeThe part table is only written by updatePart, called at text-start (empty) and text-end (full content):
case "text-start": yield* session.updatePart(ctx.currentText) // empty, creates the row
case "text-end": yield* session.updatePart(ctx.currentText) // full content, finally persistedThe TODO(v2): Temporary dual-write while migrating session messages to v2 events comment at the text-start handler confirms this is a transitional design.
→ Consequence: the full answer lives in memory but neither the DB nor (DB-backed) UI reflect it until the stream terminates.
2. Stream termination depends entirely on the upstream response.completed event
The protocol's terminal predicate:
// packages/llm/src/protocols/openai-responses.ts
terminal: (event) => TERMINAL_TYPES.has(event.type)
// TERMINAL_TYPES = { "response.completed", "response.incomplete", "response.failed" }textEnd is only emitted via Lifecycle.finish → closeOpenBlocks (packages/llm/src/protocols/utils/lifecycle.ts). If the terminal event is delayed or lost (observed more often under provider overload), opencode waits indefinitely. chunkTimeout doesn't help here because phase/keep-alive events keep arriving, so the stream isn't seen as idle.
When the user aborts, forced termination emits textEnd, the text-end handler runs updatePart with the accumulated in-memory text → the full answer appears.
The LLM parsing layer itself looks correct: openai-responses.ts step and lifecycle.textDelta emit an incremental textDelta event per response.output_text.delta.
Suggestions
- Persist text deltas incrementally (or flush in-memory text on a watchdog timer), so content isn't held hostage to the terminal event.
- Add a fallback: if
final_answeroutput is complete but no terminal event arrives within N seconds, finalize the stream (emittextEnd/finish).
Reproducibility
Not deterministic — requires the upstream to drop or delay the terminal event. Triggers more frequently under provider overload with reasoning / fast-variant models. Manually aborting the stuck subagent reveals the full content immediately, which is a strong tell.
Activity
JWebCoder commentedon Aug 1, 2026
I’m seeing a related failure in parent/subagent orchestration with OpenCode
1.18.11and an OpenAI Responses model (openai/gpt-5.6-terra), surfaced through OpenChamber1.17.1.Failure mode 1: child result does not resume/finalize the parent
A parent agent delegates work to subagents. Child sessions can complete and persist their result, but the parent does not reliably consume that result or continue its loop.
Observed persisted state:
completedAt: null);idle;This strands successful child work and breaks autonomous multi-step workflows.
Failure mode 2: delegated task instruction is persisted but never processed
I also observed child/subagent sessions where the internally generated delegated-task instruction was persisted, but no assistant message or tool execution followed.
This was not a prompt manually sent by the human user. It was the instruction OpenCode created for the child session after the parent delegated a task.
Observed state:
idle;Failure mode 3: orphaned tool part after process termination
In another affected child session, the UI showed
npm testas a running tool for several minutes.However, host process inspection confirmed that no
npm testornode --testprocess existed anymore. The tool part never transitioned tocompletedorerror, while the session itself was reported asidle.This leaves the parent task wait and the UI in inconsistent states: the UI says the child is still running a command, while the server/session status says idle and the underlying process no longer exists.
Expected behavior
For every delegated task and child instruction, OpenCode should guarantee one terminal or recoverable path:
Likewise, every tool process must result in a terminal tool-part transition (
completed,error, orcancelled) even if the process exits unexpectedly, times out, or its stream is interrupted.A session should not be reported as
idlewhile its latest parent assistant turn, delegated child-task instruction, or tool part remains incomplete.Suggested areas to inspect
This appears related to missing final-answer persistence / terminal stream-event behavior, but at the parent/subagent and tool-lifecycle boundaries.
Could you inspect cases where:
idle;The important invariant is that persisted message completion, tool completion, task completion, and session-status transitions must remain consistent and observable after every success, timeout, cancellation, process exit, or stream failure.
github-actions commentedon Oct 1, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.