Skip to content

fix(server): subagent approvals always land in the parent run - #13726

Merged
juliusmarminge merged 3 commits into
t3code/codex-turn-mappingfrom
v2/subagent-requests-in-parent
Sep 26, 2026
Merged

juliusmarminge merged 3 commits into
t3code/codex-turn-mappingfrom
v2/subagent-requests-in-parent

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

When an OpenCode task subagent was already running a turn and asked for a permission or a question, the adapter wrote the request to the subagent's own thread. That thread is hidden from the sidebar and has no composer, so the user never saw the request and the parent looked busy while OpenCode waited. The maintainer rule is that every request a provider-native subagent raises lands in the top-level parent run, for every provider and at any nesting depth.

Follows #13703 (Codex) and uses the same pattern.

What changed

  • OpenCode adapter. permission.asked and question.asked now go through a single routeRuntimeRequest path. topLevelRequestOwner follows parentSubagent links up to the top-level thread and its active turn, the way approvalOwnerCodexTurn does for Codex.
    • The request, its node and its card go to the top-level thread, run and provider turn. The node hangs under the top-level subagent that leads to the asking session.
    • The pending map is still keyed by request id, so answering from the parent reaches OpenCode's permission.reply / question.reply.
    • A child that finishes or errors still cancels any request it asked (finalizeTurn also matches on the asking session).
    • For tool-name classification, the permission's tool call is looked up in the asking session's turn.
    • The early case that fix(server): Codex subagent approvals show up in the parent thread #13703 relied on (a request that arrives before the child relation is known) still resolves through session.get.
  • OpenCode fixture opencode_running_child_approval. A task child that has already started its turn runs bash and asks for permission. The fixture asserts:
    • the request, node and card are on the parent run;
    • the node's parent is the subagent;
    • the parent shell reports the request while it is pending, and the child shell does not;
    • the child holds no request;
    • approving from the parent finishes the child and the parent.
  • Codex fixture subagent_v2_nested_approval. Recorded live on Codex 0.156.1 with gpt-6-luna under the approval-required policy (untrusted / readOnly, agents.max_depth: 2). Root spawns a subagent, which spawns a grandchild that writes a file.
    • Codex asks on the grandchild's native thread and turn (item/commandExecution/requestApproval with the grandchild's threadId/turnId).
    • The fixture asserts the request is on the root run and only the root shell reports it. Neither child thread holds a request, and approving from the root finishes the chain.
    • The recorder gains this scenario.
  • Server cleanup.
    • Removed the runtimeLayer.test.ts test from fix(clients): native subagent threads show status instead of a composer #13624. It hand-built a Codex message-mode question on a native child, which can no longer happen. Codex 0.156 registers the async question tools only for the root agent (core/src/tools/spec_plan.rs:1169), request_user_input rejects non-root agents (core/src/tools/handlers/request_user_input.rs:67), and every adapter now asks on the parent.
    • The Claude subagent replay test still covers the message.dispatch refusal. The guard itself is unchanged; only its comment is updated.

Where the approval node hangs

For nested subagents, the node's parent is the top-level subagent on the root thread, not the grandchild's own subagent node. That grandchild node lives on the middle child's thread, and pointing to it would create a cross-thread parentNodeId, which none of the projection or rendering code expects. This matches #13703.

Per-provider verdict

Provider Where a subagent's request lands Nesting
Codex Parent (#13703). Nested case now recorded live. Walks the full chain
OpenCode Fixed here T3 keeps OpenCode's nested-task deny (openCodeChildPermissionRules; the live recording's session.update ends with task: deny). The walk is still a loop to the top level.
Claude Already parent: canUseTool reads the single root activeTurn (ClaudeAdapterV2.ts canUseToolEffect), and buildApprovalRequestArtifacts writes context.input.threadId/runId Claude subagents cannot spawn subagents, and every child thread is parented to the root
Grok / ACP registry Already parent: beginApprovalRequest / beginUserInputRequest take the single root activeTurn (AcpAdapterV2.ts activeContext). params.sessionId is never used to choose an owner. A grandchild session's request also lands on the root run. Grandchild sessions are not registered as their own subagents (spawn updates on a child session only match the child itself), so their work is flattened. That is out of scope here.
Antigravity Already parent (ACP path, no child sessions) n.a.
Cursor n.a.: the SDK exposes no interactive requests n.a.
Pi n.a.: subagents run with --no-session and get no child thread n.a.

Claude and ACP set the request node's parent to the root node (Claude uses the tool's node), not to the subagent. Attributing those requests to their subagent is a possible follow-up.

How the OpenCode fixture was made

The frames are hand-ported, with shapes taken from a live run. I installed OpenCode 1.18.32 into an isolated prefix with an isolated XDG config pointing at the proxy hub. I then drove the real orchestrator and adapter against opencode serve through a temporary recording shim (not committed).

  • The run captured a resumed task child emitting message.part.updated (tool bash, running) followed by permission.asked on its own session while its turn was active. That is exactly this bug.
  • I could not commit a full live transcript. On this base, a second prompt on the same OpenCode session fails in ProjectionStore (provider-turn.updated) before the resumed child gets that far. That is a separate issue. In single-turn runs, Sonnet 4.6 and Haiku 4.5 through OpenCode never called the child's bash tool. The hub's Codex pool returned auth_unavailable for GPT models.
  • So opencode_running_child_approval is the existing opencode_child_approval capture with the child's bash pending/running/completed parts and permission.asked inserted after the child's assistant message starts. Those four frames use the shapes from the live 1.18.32 run. The transcript header says so.

Verification

  • OrchestratorReplayFixtures.integration.test.ts -t opencode: opencode_running_child_approval fails on the unfixed adapter, because no request ever appears on the parent and the respond step times out after 60 s. It passes with the fix.
  • subagent_v2_nested_approval passes. When approvalOwnerCodexTurn is temporarily changed to walk only one level, it fails the same way (the request never reaches the root).
  • On the rebased head, these ran together: OrchestratorReplayFixtures.integration.test.ts (full), CodexReplayFixtures.integration.test.ts, OrchestratorReplayFixtures.contract.test.ts, Adapters/OpenCodeAdapterV2.test.ts and Adapters/CodexAdapterV2.test.ts. 256 passed. subagent_v2_approval and opencode_child_approval still pass.
  • After the cleanup commit: runtimeLayer.test.ts and ClaudeReplayFixtures.integration.test.ts, 47 passed.
  • vp exec tsc --noEmit -p . in apps/server: no errors or warnings. vp run knip:check passes.
  • vp lint on the touched files shows only warnings already on the base branch (layer in OpenCodeAdapterV2.ts, layerUnavailable in Orchestrator.ts, assertUserMessagesExclude).
  • Recording hygiene: the Codex recording used a copied CODEX_HOME that was deleted afterwards, and the recorder scrubbed home, hostname and installationId. The OpenCode scratch config and captures were deleted.
  • Not run: web, mobile and client tests (no client code in this PR; see the stacked client PR), and a real-client pass.

Model: Claude Opus 5.5 (Claude Code)

🤖 Generated with Claude Code


Devin Review

juliusmarminge and others added 3 commits September 25, 2026 16:49
Recorded live on Codex 0.156.1 with gpt-6-luna: the root spawns a subagent
that spawns its own subagent, which runs a write under the approval-required
policy. Codex asks on the grandchild's native thread and turn; the fixture
asserts the request is on the top-level thread and run, only the root reports
it pending, and approving there finishes the chain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A task child that was already running a turn asked for permissions and
questions on its own thread, which the sidebar hides. Every OpenCode request
now resolves to the top-level thread and its active turn, walking up through
any nesting, and hangs under the subagent that leads to the asking session.
The pending map stays keyed by request id, so answering from the parent
reaches OpenCode. A finishing child still cancels requests it asked.

The opencode_running_child_approval fixture is hand-ported from the
opencode_child_approval capture plus the bash tool part and permission.asked
shapes a running task child produced on a live OpenCode 1.18.32 run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The runtimeLayer test seeded a Codex-style message-mode question on a native
subagent thread. Codex 0.156 refuses questions from non-root agents, and every
adapter now asks a subagent's requests on the top-level parent thread, so the
state it built cannot occur. The Claude subagent replay still covers the
message.dispatch refusal. The guard's comment no longer claims answers reuse
dispatchMessage on the child.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 26, 2026
const permissionToolName =
request.type === "permission" && request.value.tool !== undefined
? turn.toolNamesByCallId.get(request.value.tool.callID)
? threads

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Adapters/OpenCodeAdapterV2.ts:1932

Early child permission requests are classified as file-read instead of file-change when their task part has not registered a thread state yet. At line 1932, threads.get(request.value.sessionID) is undefined, so toolName is discarded even though routeRuntimeRequest successfully resolves the owner through session.get; preserve or recover the asking tool name for this early-request path before calling openCodePermissionRequestKind.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts around line 1932:

Early child permission requests are classified as `file-read` instead of `file-change` when their `task` part has not registered a thread state yet. At line 1932, `threads.get(request.value.sessionID)` is `undefined`, so `toolName` is discarded even though `routeRuntimeRequest` successfully resolves the owner through `session.get`; preserve or recover the asking tool name for this early-request path before calling `openCodePermissionRequestKind`.

@macroscopeapp

macroscopeapp Bot commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Would Approve

Macroscope's review found this PR approvable — This is a localized server bug fix that reroutes hidden OpenCode subagent approvals and questions to the parent run, with targeted replay coverage for running and nested subagents. An unresolved high-severity finding remains about early-request tool classification and represents a separate blocking correctness risk.

Not approved because:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@github-actions

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire — 4.9 KiB — 6.8 KiB ✅
Codex Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Codex Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Codex Live turn WebSocket decoded — 20.4 KiB — 29.3 KiB ✅
Codex Live turn messages — 2 — 8 ✅
Claude Total thread wire — 4.9 KiB — 6.8 KiB ✅
Claude Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Claude Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Claude Live turn WebSocket decoded — 20.7 KiB — 29.3 KiB ✅
Claude Live turn messages — 1 — 8 ✅

Baseline: unavailable · PR result: 0840605 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 106.1 KiB
  • Claude decoded thread snapshot: 106.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@juliusmarminge
juliusmarminge merged commit 01dba73 into t3code/codex-turn-mapping Sep 26, 2026
24 of 25 checks passed
@juliusmarminge
juliusmarminge deleted the v2/subagent-requests-in-parent branch September 26, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant