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;