diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index d3653d30035d78603e05ff914a9e87672600e04b..cd8ba0e383adc2a95018d729a415bbe0fc943275 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, ProjectId, + RuntimeRequestId, ThreadId, type OrchestrationV2ShellSnapshot, } from "@t3tools/contracts"; @@ -9,7 +10,7 @@ import { Atom, AtomRegistry } from "effect/reactivity"; import { describe, expect, it } from "vite-plus/test"; import { PrimaryConnectionTarget } from "../connection/model.ts"; -import { v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; +import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; @@ -103,6 +104,53 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); + it("counts a subagent question on its parent only while the subagent runs", () => { + const { registry, threads, snapshotAtom } = makeHarness(); + const root = v2ThreadShell; + const child = { + ...root, + id: ThreadId.make("child"), + creationSource: "mcp" as const, + status: "running" as const, + activityRunStatus: "running" as const, + lineage: { + ...root.lineage, + parentThreadId: root.id, + relationshipToParent: "subagent" as const, + }, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("question"), + kind: "user_input" as const, + createdAt: v2Now, + }, + }; + const inputs = threads.childThreadInputsAtom({ environmentId, threadId: root.id }); + const dispose = registry.mount(inputs); + registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, child] }); + expect(registry.get(inputs).map((thread) => thread.id)).toEqual([child.id]); + // A follow-up queued behind the blocked run leaves the question live. + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [root, { ...child, status: "queued" as const }], + }); + expect(registry.get(inputs).map((thread) => thread.id)).toEqual([child.id]); + // An async question stays pending after the run that asked it completes. + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [root, { ...child, status: "completed" as const, activityRunStatus: null }], + }); + expect(registry.get(inputs)).toEqual([]); + // Older servers omit the activity run, so the latest run's status decides. + const { activityRunStatus: _omitted, ...legacyChild } = child; + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + threads: [root, { ...legacyChild, status: "completed" as const }], + }); + expect(registry.get(inputs)).toEqual([]); + dispose(); + registry.dispose(); + }); + it("leaves switched-off environments out of navigation", () => { const { registry, threads, catalogValueAtom } = makeHarness([ environmentId, diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 3cb54087ac14d233c9f8f66ae5d9e2eedf07c09f..20d47aadba0715736367d1ee621c0749ee4ff688 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -30,6 +30,20 @@ const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< ReadonlyArray > = new Map(); +// An async question outlives the subagent's run, but once that run ends the +// parent is no longer waiting on the answer, so it stops counting as input. +// The activity run stays set when a follow-up is queued behind a blocked run; +// older servers omit it, so fall back to the latest run's status. +const isChildRunLive = ( + thread: Pick, +) => + thread.activityRunStatus !== undefined + ? thread.activityRunStatus !== null + : thread.status === "preparing" || + thread.status === "starting" || + thread.status === "running" || + thread.status === "waiting"; + export function createEnvironmentThreadShellAtoms(input: { readonly catalogValueAtom: Atom.Atom; readonly snapshotAtom: ( @@ -163,6 +177,7 @@ export function createEnvironmentThreadShellAtoms(input: { pending.push(child.id); if ( child.pendingRuntimeRequest?.kind === "user_input" && + isChildRunLive(child) && !isProviderNativeSubagentThread(child) ) { next.push(scopedThread(ref.environmentId, child));