Skip to content

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

Closed as not planned

Description

@ChangeHow

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.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_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.

Activity

  1. JWebCoder commented on Aug 1, 2026

    @JWebCoder

    I’m seeing a related failure in parent/subagent orchestration with OpenCode 1.18.11 and an OpenAI Responses model (openai/gpt-5.6-terra), surfaced through OpenChamber 1.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:

    • the latest parent assistant message remains incomplete (completedAt: null);
    • the parent session may be reported as idle;
    • no terminal event, final answer, or error is persisted for the parent turn;
    • no subsequent parent step consumes the completed child result;
    • sending a new user message to the parent is required to wake/re-drive it.

    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:

    • the child session is reported as idle;
    • its latest delegated-task instruction remains incomplete;
    • there is no subsequent assistant message;
    • the requested work never starts.

    Failure mode 3: orphaned tool part after process termination

    In another affected child session, the UI showed npm test as a running tool for several minutes.

    However, host process inspection confirmed that no npm test or node --test process existed anymore. The tool part never transitioned to completed or error, while the session itself was reported as idle.

    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:

    1. the child instruction begins processing and the parent receives its result;
    2. the child/parent receives a persisted terminal error, cancellation, or timeout;
    3. the parent-side task wait resolves or rejects exactly once.

    Likewise, every tool process must result in a terminal tool-part transition (completed, error, or cancelled) even if the process exits unexpectedly, times out, or its stream is interrupted.

    A session should not be reported as idle while 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:

    • a child terminal result is produced but the parent prompt loop is not resumed;
    • a delegated child-task instruction is persisted but never scheduled;
    • a tool process terminates but its tool part does not receive a terminal event;
    • a stream/task completion event is missing, leaving incomplete persisted state while session status transitions to idle;
    • parent-side task waits/promises are left unresolved.

    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.

  2. github-actions commented on Oct 1, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions