diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 413a5c2b3f575..a14b4979f7293 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -152,6 +152,55 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); + it("ignores a subagent question left over from a run that already finished", () => { + const { registry, threads, snapshotAtom } = makeHarness(); + const child = { + ...v2ThreadShell, + id: ThreadId.make("child"), + creationSource: "mcp" as const, + status: "running" as const, + activityRunStatus: "running" as const, + activityRunStartedAt: DateTime.add(v2Now, { minutes: 5 }), + lineage: { + ...v2ThreadShell.lineage, + parentThreadId: v2ThreadShell.id, + relationshipToParent: "subagent" as const, + }, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("question"), + kind: "user_input" as const, + createdAt: v2Now, + }, + }; + const root = { + ...v2ThreadShell, + latestRunId: RunId.make("root-run"), + status: "completed" as const, + pendingBackgroundTasks: [ + { taskId: "lint", kind: "subagent" as const, childThreadId: child.id }, + ], + }; + 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); + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, child] }); + expect(registry.get(inputs)).toEqual([]); + expect(registry.get(parent)?.pendingSubagentRequest).toBeNull(); + expect(isThreadWorking(registry.get(parent)!)).toBe(true); + + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [root, { ...child, activityRunStartedAt: DateTime.subtract(v2Now, { minutes: 5 }) }], + }); + expect(registry.get(inputs).map((thread) => thread.id)).toEqual([child.id]); + expect(registry.get(parent)?.pendingSubagentRequest).toBe("user_input"); + expect(isThreadWorking(registry.get(parent)!)).toBe(false); + disposeInputs(); + disposeParent(); + registry.dispose(); + }); + it("brings a parent back from Working while a nested subagent waits for approval", () => { const { registry, threads, snapshotAtom } = makeHarness(); const child = { diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index a462638144bf0..242d3956b3288 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -7,6 +7,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import { isProviderNativeSubagentThread, OrchestrationV2ThreadShell } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; import { Atom } from "effect/reactivity"; @@ -45,6 +46,16 @@ const isChildRunLive = ( thread.status === "running" || thread.status === "waiting"; +// Compare against the work start: wakes and restarts may continue a pending +// question, whereas a fresh run must not revive one from an earlier run. +const isStaleQuestion = ( + thread: Pick, +) => + thread.pendingRuntimeRequest?.kind === "user_input" && + thread.activityRunStartedAt != null && + DateTime.toEpochMillis(thread.pendingRuntimeRequest.createdAt) < + DateTime.toEpochMillis(thread.activityRunStartedAt); + /** Provider-native subagents ask on their parent, so they are skipped. */ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentRequest | null { @@ -54,7 +65,8 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq kind === "auth_refresh" || thread.lineage.relationshipToParent !== "subagent" || isProviderNativeSubagentThread(thread) || - !isChildRunLive(thread) + !isChildRunLive(thread) || + isStaleQuestion(thread) ) { return null; }