From f0748249148903dc4ffa3f86a74bc3544f296684 Mon Sep 17 00:00:00 2001 From: Omar <76748259+omar9848492@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:23:21 -0400 Subject: [PATCH] fix: subagent questions go to the orchestrator before the user A delegated subagent's question is already handed to its parent agent, which can answer it. The parent thread now shows it to the user only once that agent stops without answering, so an orchestrator handling its subagents stays in Working. Approvals and secrets, which only the user can answer, still surface right away. The server marks runtime questions `secret: false`, so questions from older servers keep surfacing at once. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/ProjectionStore.test.ts | 2 + .../src/orchestration-v2/ProjectionStore.ts | 2 + docs/user/thread-sidebar.md | 5 +- .../src/state/threadShell.test.ts | 82 ++++++++++++++++++- .../client-runtime/src/state/threadShell.ts | 20 ++++- packages/contracts/src/orchestrationV2.ts | 5 ++ 6 files changed, 112 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 83f33269f3992..06b19964b8b3f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2808,6 +2808,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { kind: "user_input", createdAt: now, blocking, + secret: false, }); } } @@ -3870,6 +3871,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ]) { assert.equal(shell?.pendingRuntimeRequest?.id, "secret:b"); assert.isTrue(shell?.pendingRuntimeRequest?.blocking); + assert.isTrue(shell?.pendingRuntimeRequest?.secret); } const requestId = RuntimeRequestId.make(`request:${suffix}:async`); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 3fb7c15257801..7578675adc102 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1488,6 +1488,7 @@ function pendingRuntimeRequestSummary( kind: request.kind, createdAt: request.createdAt, blocking: request.responseCapability.type !== "message", + secret: false, }; } @@ -1500,6 +1501,7 @@ function secretRequestAsPendingInput( kind: "user_input", createdAt: item.startedAt ?? item.updatedAt, blocking: true, + secret: true, }; } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index c64e95ec52931..b58752a585343 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -220,8 +220,9 @@ On web and desktop, use **Agents** to follow work delegated to subagents. Stop on a thread also stops the subagents it delegated to. Subagent threads started by the agent can't take messages; message the parent -thread instead. When such a subagent needs an approval or an answer, the parent -thread asks for it. +thread instead. When such a subagent needs an approval or a secret, the parent +thread asks you for it. Its questions go to the parent agent first, and reach you +only if that agent stops without answering. Expand a tool call in the conversation to see its full command and output. Summaries shorten shell wrappers and can still describe the latest call after it diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 2dcc3233db3a9..21dc5f0fb5c83 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -16,7 +16,11 @@ import { PrimaryConnectionTarget } from "../connection/model.ts"; import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import { isThreadWorking } from "./threadInbox.ts"; -import { childThreadInputsTitle, createEnvironmentThreadShellAtoms } from "./threadShell.ts"; +import { + childThreadInputsTitle, + createEnvironmentThreadShellAtoms, + pendingSubagentRequestsByThread, +} from "./threadShell.ts"; const environmentId = EnvironmentId.make("environment-v2"); const remoteEnvironmentId = EnvironmentId.make("remote-environment-v2"); @@ -215,6 +219,82 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); + it("routes a subagent question to its parent agent until that agent stops", () => { + const { registry, threads, snapshotAtom } = makeHarness(); + const root = { + ...v2ThreadShell, + latestRunId: RunId.make("root-run"), + status: "running" as const, + activityRunStatus: "running" as const, + }; + const child = { + ...v2ThreadShell, + id: ThreadId.make("child"), + creationSource: "mcp" as const, + status: "running" as const, + activityRunStatus: "running" as const, + lineage: { + ...v2ThreadShell.lineage, + parentThreadId: root.id, + relationshipToParent: "subagent" as const, + }, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("question"), + kind: "user_input" as const, + createdAt: v2Now, + secret: false, + }, + }; + const parent = threads.threadShellAtom({ environmentId, threadId: root.id }); + const inputs = threads.childThreadInputsAtom({ environmentId, threadId: root.id }); + const disposeParent = registry.mount(parent); + const disposeInputs = registry.mount(inputs); + const show = (...shells: ReadonlyArray) => { + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: shells }); + return { + request: registry.get(parent)?.pendingSubagentRequest, + inputs: registry.get(inputs).map((thread) => thread.id), + }; + }; + + expect(show(root, child)).toEqual({ request: null, inputs: [] }); + expect(isThreadWorking(registry.get(parent)!)).toBe(true); + const { activityRunStatus: _omitted, ...legacyRoot } = root; + expect(show(legacyRoot, child)).toEqual({ request: null, inputs: [] }); + const wakeQueued = { ...root, status: "queued" as const, activityRunStatus: null }; + expect(show(wakeQueued, child)).toEqual({ request: null, inputs: [] }); + const stopped = { ...root, status: "completed" as const, activityRunStatus: null }; + expect(show(stopped, child)).toEqual({ request: "user_input", inputs: [child.id] }); + expect(pendingSubagentRequestsByThread([child]).get(root.id)).toBe("user_input"); + const secret = { ...child.pendingRuntimeRequest, blocking: true, secret: true }; + expect(show(root, { ...child, pendingRuntimeRequest: secret })).toEqual({ + request: "user_input", + inputs: [child.id], + }); + const { secret: _unmarked, ...olderServerQuestion } = child.pendingRuntimeRequest; + expect(show(root, { ...child, pendingRuntimeRequest: olderServerQuestion })).toEqual({ + request: "user_input", + inputs: [child.id], + }); + + const grandchild = { + ...child, + id: ThreadId.make("grandchild"), + lineage: { ...child.lineage, parentThreadId: child.id }, + }; + const busyChild = { ...child, pendingRuntimeRequest: null }; + expect(show(stopped, busyChild, grandchild)).toEqual({ request: null, inputs: [] }); + + const approval = { ...child.pendingRuntimeRequest, kind: "command" as const }; + expect(show(root, { ...child, pendingRuntimeRequest: approval })).toEqual({ + request: "approval", + inputs: [child.id], + }); + disposeInputs(); + disposeParent(); + registry.dispose(); + }); + it.each([ { reason: "switched-off", state: { enabled: false } }, { diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 8d61ff6ac5677..9e291738a3a6b 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -61,6 +61,22 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq return kind === "user_input" ? "user_input" : "approval"; } +// The server hands a subagent's question to its parent agent, which can answer +// it, so the user sees it only once that parent stops working. Secrets and +// approvals need the user and surface at once, as do questions from older +// servers, which send no secret marker. A queue held for the user reports its +// last finished run rather than "queued", so its question escalates. +function heldForParentAgent( + child: OrchestrationV2ThreadShell, + threadsById: ReadonlyMap, +): boolean { + const request = child.pendingRuntimeRequest; + if (request?.kind !== "user_input" || request.secret !== false) return false; + const parentId = child.lineage.parentThreadId; + const parent = parentId === null ? undefined : threadsById.get(parentId); + return parent !== undefined && (isChildRunLive(parent) || parent.status === "queued"); +} + export function pendingSubagentRequestsByThread( threads: ReadonlyArray, ): ReadonlyMap { @@ -70,6 +86,7 @@ export function pendingSubagentRequestsByThread( const request = subagentRequest(child); if (request === null) continue; byId ??= new Map(threads.map((thread) => [thread.id, thread] as const)); + if (heldForParentAgent(child, byId)) continue; const seen = new Set([child.id]); let current: OrchestrationV2ThreadShell | undefined = child; while (current?.lineage.relationshipToParent === "subagent") { @@ -294,6 +311,7 @@ export function createEnvironmentThreadShellAtoms(input: { else children.set(parent, [thread]); } const subagentRequests = get(pendingSubagentRequestsAtom(ref.environmentId)); + const threadsById = get(environmentThreadIndexAtom(ref.environmentId)); const seen = new Set([ref.threadId]); const pending = [ref.threadId]; const next: EnvironmentThreadShell[] = []; @@ -302,7 +320,7 @@ export function createEnvironmentThreadShellAtoms(input: { if (seen.has(child.id)) continue; seen.add(child.id); pending.push(child.id); - if (subagentRequest(child) !== null) { + if (subagentRequest(child) !== null && !heldForParentAgent(child, threadsById)) { next.push(scopedThread(ref.environmentId, child, subagentRequests)); } } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 42483d96fee56..59a59218734f5 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1790,6 +1790,11 @@ export const OrchestrationV2PendingRuntimeRequestSummary = Schema.Struct({ createdAt: Schema.DateTimeUtc, /** False for async questions the run keeps working past. Absent from older servers. */ blocking: Schema.optional(Schema.Boolean), + /** + * True for request_secret prompts, which only the user can answer; false for runtime requests. + * Absent from older servers. + */ + secret: Schema.optional(Schema.Boolean), }); export type OrchestrationV2PendingRuntimeRequestSummary = typeof OrchestrationV2PendingRuntimeRequestSummary.Type;