anomalyco / anomalyco/opencode

Subagent text not persisted during final_answer phase; full content only appears on abort (OpenAI Responses)

Open
#39,375 1 comment 0 reactions 1 assignee View on GitHub

@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_answer phase. A text part is created in the DB with empty text and time.start set (provider metadata carries phase: final_answer, itemId).
  • T+0 s → T+~10 min: The part's text remains empty. No new parts, no stream error, no timeout fired. The text part's time.end is unset. UI keeps spinning. chunkTimeout does not fire.
  • T+~10 min (user clicks Abort):
    • ~70 ms later the text part is populated with the full, complete final answer (time.end set).
    • ~160 ms later the parent session resumes its loop and continues normally.

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.finishcloseOpenBlocks (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_answer output is complete but no terminal event arrives within N seconds, finalize the stream (emit textEnd / 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

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.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.