anomalyco / anomalyco/opencode
Subagent text not persisted during final_answer phase; full content only appears on abort (OpenAI Responses)
@rekram1-node is already working on this.
Since Jul 28, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
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 write
The 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 persisted
The 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.
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.
Assessment
This issue has not been assessed yet.