authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-10-14 07:32:09+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 07:32:09+05:30
log109aaae6cd251e1d3aa595624209758a164d8b33
tree8a051494bbbb3c15a5c5bc95252045e9c050786c
parenta4e2539483495d000a30f5f3c01aadb36f13dcec
signature Signed by PGP key B5690EEEBB952194

fix(client): "Subagent needs input" no longer sticks after the subagent finishes (#18996)

Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 64 insertions(+), 1 deletions(-)

packages/client-runtime/src/state/threadShell.test.ts+49-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import {1import {
2 EnvironmentId,2 EnvironmentId,
3 ProjectId,3 ProjectId,
4 RuntimeRequestId,
4 ThreadId,5 ThreadId,
5 type OrchestrationV2ShellSnapshot,6 type OrchestrationV2ShellSnapshot,
6} from "@t3tools/contracts";7} from "@t3tools/contracts";
...@@ -9,7 +10,7 @@ import { Atom, AtomRegistry } from "effect/reactivity";...@@ -9,7 +10,7 @@ import { Atom, AtomRegistry } from "effect/reactivity";
9import { describe, expect, it } from "vite-plus/test";10import { describe, expect, it } from "vite-plus/test";
1011
11import { PrimaryConnectionTarget } from "../connection/model.ts";12import { PrimaryConnectionTarget } from "../connection/model.ts";
12import { v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";13import { v2Now, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
13import { applyShellStreamEvent } from "./shellReducer.ts";14import { applyShellStreamEvent } from "./shellReducer.ts";
14import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";15import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";
1516
...@@ -103,6 +104,53 @@ describe("v2 thread shell lists", () => {...@@ -103,6 +104,53 @@ describe("v2 thread shell lists", () => {
103 registry.dispose();104 registry.dispose();
104 });105 });
105106
107 it("counts a subagent question on its parent only while the subagent runs", () => {
108 const { registry, threads, snapshotAtom } = makeHarness();
109 const root = v2ThreadShell;
110 const child = {
111 ...root,
112 id: ThreadId.make("child"),
113 creationSource: "mcp" as const,
114 status: "running" as const,
115 activityRunStatus: "running" as const,
116 lineage: {
117 ...root.lineage,
118 parentThreadId: root.id,
119 relationshipToParent: "subagent" as const,
120 },
121 pendingRuntimeRequest: {
122 id: RuntimeRequestId.make("question"),
123 kind: "user_input" as const,
124 createdAt: v2Now,
125 },
126 };
127 const inputs = threads.childThreadInputsAtom({ environmentId, threadId: root.id });
128 const dispose = registry.mount(inputs);
129 registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, child] });
130 expect(registry.get(inputs).map((thread) => thread.id)).toEqual([child.id]);
131 // A follow-up queued behind the blocked run leaves the question live.
132 registry.set(snapshotAtom(environmentId), {
133 ...v2ShellSnapshot,
134 threads: [root, { ...child, status: "queued" as const }],
135 });
136 expect(registry.get(inputs).map((thread) => thread.id)).toEqual([child.id]);
137 // An async question stays pending after the run that asked it completes.
138 registry.set(snapshotAtom(environmentId), {
139 ...v2ShellSnapshot,
140 threads: [root, { ...child, status: "completed" as const, activityRunStatus: null }],
141 });
142 expect(registry.get(inputs)).toEqual([]);
143 // Older servers omit the activity run, so the latest run's status decides.
144 const { activityRunStatus: _omitted, ...legacyChild } = child;
145 registry.set(snapshotAtom(environmentId), {
146 ...v2ShellSnapshot,
147 threads: [root, { ...legacyChild, status: "completed" as const }],
148 });
149 expect(registry.get(inputs)).toEqual([]);
150 dispose();
151 registry.dispose();
152 });
153
106 it("leaves switched-off environments out of navigation", () => {154 it("leaves switched-off environments out of navigation", () => {
107 const { registry, threads, catalogValueAtom } = makeHarness([155 const { registry, threads, catalogValueAtom } = makeHarness([
108 environmentId,156 environmentId,
packages/client-runtime/src/state/threadShell.ts+15
...@@ -30,6 +30,20 @@ const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<...@@ -30,6 +30,20 @@ const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap<
30 ReadonlyArray<ScopedThreadRef>30 ReadonlyArray<ScopedThreadRef>
31> = new Map();31> = new Map();
3232
33// An async question outlives the subagent's run, but once that run ends the
34// parent is no longer waiting on the answer, so it stops counting as input.
35// The activity run stays set when a follow-up is queued behind a blocked run;
36// older servers omit it, so fall back to the latest run's status.
37const isChildRunLive = (
38 thread: Pick<OrchestrationV2ThreadShell, "activityRunStatus" | "status">,
39) =>
40 thread.activityRunStatus !== undefined
41 ? thread.activityRunStatus !== null
42 : thread.status === "preparing" ||
43 thread.status === "starting" ||
44 thread.status === "running" ||
45 thread.status === "waiting";
46
33export function createEnvironmentThreadShellAtoms(input: {47export function createEnvironmentThreadShellAtoms(input: {
34 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;48 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
35 readonly snapshotAtom: (49 readonly snapshotAtom: (
...@@ -163,6 +177,7 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -163,6 +177,7 @@ export function createEnvironmentThreadShellAtoms(input: {
163 pending.push(child.id);177 pending.push(child.id);
164 if (178 if (
165 child.pendingRuntimeRequest?.kind === "user_input" &&179 child.pendingRuntimeRequest?.kind === "user_input" &&
180 isChildRunLive(child) &&
166 !isProviderNativeSubagentThread(child)181 !isProviderNativeSubagentThread(child)
167 ) {182 ) {
168 next.push(scopedThread(ref.environmentId, child));183 next.push(scopedThread(ref.environmentId, child));