authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 00:24:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 00:24:44-07:00
log00f8b7c28056188e3c5630160806a0afe51c9010
tree32dd9a8efefcfaa6a74a69ee06445ec5b96b7826
parentf1e90e388b86fe4b007a55c0e685a1fa878115e6
signature Signed by PGP key B5690EEEBB952194

fix: show idle subagent batches without completion marks (#9616)


11 files changed, 200 insertions(+), 52 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+15-11
...@@ -2256,9 +2256,13 @@ describe("quiet timeline: nested agents", () => {...@@ -2256,9 +2256,13 @@ describe("quiet timeline: nested agents", () => {
2256 },2256 },
2257 );2257 );
22582258
2259 it.each(["cancelled", "failed", "interrupted"] as const)(2259 it.each(["cancelled", "failed", "interrupted", "idle"] as const)(
2260 "replaces Antigravity progress with %s without a timeline bypass flag",2260 "replaces Antigravity batch progress with %s",
2261 (status) => {2261 (status) => {
2262 const detail =
2263 status === "idle"
2264 ? "Turn ended. Individual agent status is unavailable."
2265 : "Antigravity process stopped.";
2262 const thread = makeThread({2266 const thread = makeThread({
2263 id: ThreadId.make("antigravity-agents"),2267 id: ThreadId.make("antigravity-agents"),
2264 projectId: ProjectId.make("project-1"),2268 projectId: ProjectId.make("project-1"),
...@@ -2268,14 +2272,14 @@ describe("quiet timeline: nested agents", () => {...@@ -2268,14 +2272,14 @@ describe("quiet timeline: nested agents", () => {
2268 makeActivity({2272 makeActivity({
2269 id: EventId.make(`progress-${index}`),2273 id: EventId.make(`progress-${index}`),
2270 kind: "task.progress",2274 kind: "task.progress",
2271 summary: "Antigravity subagent",2275 summary: "Antigravity subagent batch",
2272 createdAt: `2026-04-01T00:00:0${index + 1}.000Z`,2276 createdAt: `2026-04-01T00:00:0${index + 1}.000Z`,
2273 payload: {2277 payload: {
2274 taskId,2278 taskId,
2275 taskType: "subagent",2279 taskType: "subagent_batch",
2276 agentKind: "agent",2280 agentKind: "agent",
2277 title: "Antigravity subagent",2281 title: "Antigravity subagent batch",
2278 detail: "Antigravity subagent",2282 detail: "Antigravity subagent batch",
2279 status: "running",2283 status: "running",
2280 },2284 },
2281 }),2285 }),
...@@ -2287,11 +2291,11 @@ describe("quiet timeline: nested agents", () => {...@@ -2287,11 +2291,11 @@ describe("quiet timeline: nested agents", () => {
2287 createdAt: "2026-04-01T00:00:03.000Z",2291 createdAt: "2026-04-01T00:00:03.000Z",
2288 payload: {2292 payload: {
2289 taskId: "trajectory:4",2293 taskId: "trajectory:4",
2290 taskType: "subagent",2294 taskType: "subagent_batch",
2291 agentKind: "agent",2295 agentKind: "agent",
2292 title: "Antigravity subagent",2296 title: "Antigravity subagent batch",
2293 status,2297 status,
2294 error: "Antigravity process stopped.",2298 ...(status === "idle" ? { detail, timelineBypass: true } : { error: detail }),
2295 },2299 },
2296 }),2300 }),
2297 ],2301 ],
...@@ -2302,8 +2306,8 @@ describe("quiet timeline: nested agents", () => {...@@ -2302,8 +2306,8 @@ describe("quiet timeline: nested agents", () => {
2302 expect(rows).toHaveLength(2);2306 expect(rows).toHaveLength(2);
2303 expect(rows[0]).toMatchObject({2307 expect(rows[0]).toMatchObject({
2304 lifecycleStatus: status === "failed" ? "failed" : "stopped",2308 lifecycleStatus: status === "failed" ? "failed" : "stopped",
2305 detail: "Antigravity process stopped.",2309 detail,
2306 workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent" },2310 workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" },
2307 });2311 });
2308 expect(rows[1]).toMatchObject({2312 expect(rows[1]).toMatchObject({
2309 lifecycleStatus: "inProgress",2313 lifecycleStatus: "inProgress",
apps/mobile/src/lib/threadActivity.ts+5-1
...@@ -449,6 +449,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -449,6 +449,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
449 const taskDetailAsLabel =449 const taskDetailAsLabel =
450 isTaskActivity &&450 isTaskActivity &&
451 !taskSummary &&451 !taskSummary &&
452 !title &&
452 typeof payload?.detail === "string" &&453 typeof payload?.detail === "string" &&
453 payload.detail.length > 0454 payload.detail.length > 0
454 ? payload.detail455 ? payload.detail
...@@ -499,7 +500,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -499,7 +500,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
499 toolName: data?.toolName,500 toolName: data?.toolName,
500 data,501 data,
501 });502 });
502 if (detail && !repeatsCommand) entry.detail = detail;503 if (detail && detail !== title && !repeatsCommand) entry.detail = detail;
503 }504 }
504 if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) {505 if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) {
505 entry.detail = payload.error;506 entry.detail = payload.error;
...@@ -1110,6 +1111,9 @@ function extractWorkLogToolLifecycleStatus(...@@ -1110,6 +1111,9 @@ function extractWorkLogToolLifecycleStatus(
1110 payload: Record<string, unknown> | null,1111 payload: Record<string, unknown> | null,
1111): WorkLogToolLifecycleStatus | undefined {1112): WorkLogToolLifecycleStatus | undefined {
1112 const status = payload?.status;1113 const status = payload?.status;
1114 // The parent turn ended, so batch tracking is inactive. The detail explains
1115 // that child status is unavailable; do not retain the earlier running marker.
1116 if (status === "idle" && payload?.taskType === "subagent_batch") return "stopped";
1113 if (status === "pending" || status === "running" || status === "waiting") return "inProgress";1117 if (status === "pending" || status === "running" || status === "waiting") return "inProgress";
1114 if (status === "cancelled" || status === "interrupted") return "stopped";1118 if (status === "cancelled" || status === "interrupted") return "stopped";
1115 if (1119 if (
apps/server/src/provider/Layers/AntigravityAdapter.test.ts+3-2
...@@ -796,6 +796,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {...@@ -796,6 +796,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {
796 expect(launched.payload).toMatchObject({796 expect(launched.payload).toMatchObject({
797 taskId: started.toolCall.toolCallId,797 taskId: started.toolCall.toolCallId,
798 title: "Antigravity subagent batch",798 title: "Antigravity subagent batch",
799 taskType: "subagent_batch",
799 description: "Launch subagents",800 description: "Launch subagents",
800 status: "running",801 status: "running",
801 });802 });
...@@ -857,7 +858,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {...@@ -857,7 +858,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {
857 const completed = yield* h.waitForEvent((event) => event.type === "task.completed");858 const completed = yield* h.waitForEvent((event) => event.type === "task.completed");
858 expect(completed.payload).toEqual({859 expect(completed.payload).toEqual({
859 taskId: "replayed:4",860 taskId: "replayed:4",
860 taskType: "subagent",861 taskType: "subagent_batch",
861 toolUseId: "replayed:4",862 toolUseId: "replayed:4",
862 title: "Antigravity subagent batch",863 title: "Antigravity subagent batch",
863 status: "failed",864 status: "failed",
...@@ -1085,7 +1086,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {...@@ -1085,7 +1086,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {
1085 expect(settled.payload).toMatchObject({1086 expect(settled.payload).toMatchObject({
1086 taskId: "trajectory:4",1087 taskId: "trajectory:4",
1087 title: "Antigravity subagent batch",1088 title: "Antigravity subagent batch",
1088 taskType: "subagent",1089 taskType: "subagent_batch",
1089 status:1090 status:
1090 stop === "disconnect"1091 stop === "disconnect"
1091 ? "failed"1092 ? "failed"
apps/server/src/provider/Layers/AntigravityAdapter.ts+1-1
...@@ -176,7 +176,7 @@ interface OpenSubagent {...@@ -176,7 +176,7 @@ interface OpenSubagent {
176function subagentLinkage(toolCallId: string) {176function subagentLinkage(toolCallId: string) {
177 return {177 return {
178 taskId: RuntimeTaskId.make(toolCallId),178 taskId: RuntimeTaskId.make(toolCallId),
179 taskType: "subagent",179 taskType: "subagent_batch",
180 toolUseId: toolCallId,180 toolUseId: toolCallId,
181 title: "Antigravity subagent batch",181 title: "Antigravity subagent batch",
182 };182 };
apps/web/src/components/AgentsPanel.tsx+4-2
...@@ -140,6 +140,8 @@ function agentActivityText(agent: RuntimeSubagent): string | null {...@@ -140,6 +140,8 @@ function agentActivityText(agent: RuntimeSubagent): string | null {
140/** Flat, non-interactive agent status line. No unfold. */140/** Flat, non-interactive agent status line. No unfold. */
141function AgentRow({ agent }: { agent: RuntimeSubagent }) {141function AgentRow({ agent }: { agent: RuntimeSubagent }) {
142 const visuals = STATUS_VISUALS[agent.status];142 const visuals = STATUS_VISUALS[agent.status];
143 const statusLabel =
144 agent.kind === "subagent_batch" && agent.status === "idle" ? "Idle" : visuals.label;
143 const activity = agentActivityText(agent);145 const activity = agentActivityText(agent);
144 const modelLabel = formatSubagentModelLabel(agent.model, agent.effort);146 const modelLabel = formatSubagentModelLabel(agent.model, agent.effort);
145 const role =147 const role =
...@@ -180,12 +182,12 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) {...@@ -180,12 +182,12 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) {
180 agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground",182 agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground",
181 )}183 )}
182 >184 >
183 {activity ?? visuals.label}185 {activity ?? statusLabel}
184 </span>186 </span>
185 <span className="col-start-2 col-end-4 row-start-3 truncate font-mono text-[.7rem] tabular-nums text-muted-foreground/70">187 <span className="col-start-2 col-end-4 row-start-3 truncate font-mono text-[.7rem] tabular-nums text-muted-foreground/70">
186 {metadata.join(" · ")}188 {metadata.join(" · ")}
187 </span>189 </span>
188 <span className="sr-only">{visuals.label}</span>190 <span className="sr-only">{statusLabel}</span>
189 </div>191 </div>
190 );192 );
191}193}
apps/web/src/components/chat/MessagesTimeline.tsx+15-32
...@@ -167,6 +167,7 @@ import {...@@ -167,6 +167,7 @@ import {
167 formatInlineTerminalContextLabel,167 formatInlineTerminalContextLabel,
168 textContainsInlineTerminalContextLabels,168 textContainsInlineTerminalContextLabels,
169} from "./userMessageTerminalContexts";169} from "./userMessageTerminalContexts";
170import { deriveAgentSpawnSummary } from "./agentSpawnSummary";
170import { SkillInlineText } from "./SkillInlineText";171import { SkillInlineText } from "./SkillInlineText";
171import { formatWorkspaceRelativePath } from "../../filePathDisplay";172import { formatWorkspaceRelativePath } from "../../filePathDisplay";
172import {173import {
...@@ -2993,22 +2994,12 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time...@@ -2993,22 +2994,12 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
2993 Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0),2994 Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0),
2994 );2995 );
29952996
2996 const running = agents.filter(2997 const summary = deriveAgentSpawnSummary({
2997 (agent) => agent.status === "running" || agent.status === "pending",2998 agents,
2998 ).length;2999 agentCount,
2999 const waiting = agents.filter((agent) => agent.status === "waiting").length;3000 coordinatorStatus: workflowGroup?.workflow.status,
3000 const failed = agents.filter((agent) => agent.status === "failed").length;3001 });
3001 // The coordinator's own status is authoritative for workflows: dynamic3002 const { live, lead } = summary;
3002 // spawns mean the member list can be momentarily all-settled while the
3003 // run is still mid-flight (the "completed" lie from live testing). A
3004 // workflow is live until the coordinator itself reaches a terminal state.
3005 const coordinatorStatus = workflowGroup?.workflow.status;
3006 const coordinatorSettled =
3007 coordinatorStatus === "completed" ||
3008 coordinatorStatus === "failed" ||
3009 coordinatorStatus === "cancelled" ||
3010 coordinatorStatus === "interrupted";
3011 const live = workflowGroup !== undefined ? !coordinatorSettled : running + waiting > 0;
3012 // Same rule as the panel footer: providers may aggregate member usage into3003 // Same rule as the panel footer: providers may aggregate member usage into
3013 // the coordinator, so count the coordinator only when no members exist.3004 // the coordinator, so count the coordinator only when no members exist.
3014 const totalTokens = agents.reduce(3005 const totalTokens = agents.reduce(
...@@ -3020,22 +3011,14 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time...@@ -3020,22 +3011,14 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
3020 const workflowName =3011 const workflowName =
3021 workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null;3012 workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null;
30223013
3023 // One steady in-flight presentation (monitoring-pill rule): waiting and3014 const dotClass = {
3024 // stalled agents read as working; only settled states differentiate.3015 working: "bg-info",
3025 const working = running + waiting;3016 failed: "bg-destructive",
3026 const dotClass = live ? "bg-info" : failed > 0 ? "bg-destructive" : "bg-success";3017 completed: "bg-success",
3027 const lead = live3018 inactive: "bg-muted-foreground/50",
3028 ? `Kicked off ${agentCount} subagent${agentCount === 1 ? "" : "s"}`3019 }[summary.tone];
3029 : `Ran ${agentCount} subagent${agentCount === 1 ? "" : "s"}`;3020 const status =
3030 const status = live3021 live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status;
3031 ? livePhase
3032 ? `${livePhase.title} · ${livePhase.activeCount} working`
3033 : working > 0
3034 ? `${working} working`
3035 : "working"
3036 : failed > 0
3037 ? `${failed} failed`
3038 : "✓ completed";
30393022
3040 return (3023 return (
3041 <button3024 <button
apps/web/src/components/chat/agentSpawnSummary.test.ts created+73
...@@ -0,0 +1,73 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime";
3import { deriveAgentSpawnSummary } from "./agentSpawnSummary";
4
5const batch = (status: RuntimeSubagent["status"]) => ({ kind: "subagent_batch" as const, status });
6const agent = (status: RuntimeSubagent["status"]) => ({ kind: "subagent" as const, status });
7
8describe("deriveAgentSpawnSummary", () => {
9 it("counts a native batch without claiming the number of children", () => {
10 expect(deriveAgentSpawnSummary({ agents: [batch("running")], agentCount: 1 })).toEqual({
11 live: true,
12 lead: "Launched 1 subagent batch",
13 status: "1 working",
14 tone: "working",
15 });
16 expect(deriveAgentSpawnSummary({ agents: [batch("idle")], agentCount: 1 })).toEqual({
17 live: false,
18 lead: "Launched 1 subagent batch",
19 status: "1 idle",
20 tone: "inactive",
21 });
22 });
23
24 it("keeps individual agents and batches separate in a mixed group", () => {
25 expect(
26 deriveAgentSpawnSummary({
27 agents: [agent("running"), batch("running"), batch("idle")],
28 agentCount: 3,
29 }).lead,
30 ).toBe("Launched 1 subagent and 2 batches");
31 });
32
33 it.each([
34 ["idle", "1 idle", "inactive"],
35 ["cancelled", "1 stopped", "inactive"],
36 ["interrupted", "1 stopped", "inactive"],
37 ["failed", "1 failed", "failed"],
38 ["completed", "✓ completed", "completed"],
39 ] as const)("reports %s accurately alongside a completed agent", (state, status, tone) => {
40 expect(
41 deriveAgentSpawnSummary({ agents: [agent("completed"), agent(state)], agentCount: 2 }),
42 ).toMatchObject({ live: false, status, tone });
43 });
44
45 it("does not claim completion when the roster is missing a member", () => {
46 expect(deriveAgentSpawnSummary({ agents: [agent("completed")], agentCount: 2 })).toMatchObject({
47 status: "Status unavailable",
48 tone: "inactive",
49 });
50 });
51
52 it("keeps a workflow active between child launches", () => {
53 expect(
54 deriveAgentSpawnSummary({
55 agents: [agent("completed")],
56 agentCount: 1,
57 coordinatorStatus: "running",
58 }),
59 ).toMatchObject({ live: true, status: "working", tone: "working" });
60 });
61
62 it.each([
63 ["failed", "Workflow failed", "failed"],
64 ["cancelled", "Workflow stopped", "inactive"],
65 ] as const)(
66 "preserves a %s workflow outcome when its children completed",
67 (coordinatorStatus, status, tone) => {
68 expect(
69 deriveAgentSpawnSummary({ agents: [agent("completed")], agentCount: 1, coordinatorStatus }),
70 ).toMatchObject({ live: false, status, tone });
71 },
72 );
73});
apps/web/src/components/chat/agentSpawnSummary.ts created+64
...@@ -0,0 +1,64 @@
1import {
2 isActiveSubagentStatus,
3 isTerminalSubagentStatus,
4 type RuntimeSubagent,
5} from "@t3tools/client-runtime/state/subagentRuntime";
6
7/** Summarize observed states without treating idle or missing agents as completed. */
8export function deriveAgentSpawnSummary({
9 agents,
10 agentCount,
11 coordinatorStatus,
12}: {
13 agents: ReadonlyArray<Pick<RuntimeSubagent, "kind" | "status">>;
14 agentCount: number;
15 coordinatorStatus?: RuntimeSubagent["status"] | undefined;
16}) {
17 const working = agents.filter((agent) => isActiveSubagentStatus(agent.status)).length;
18 const failed = agents.filter((agent) => agent.status === "failed").length;
19 const idle = agents.filter((agent) => agent.status === "idle").length;
20 const stopped = agents.filter(
21 (agent) => agent.status === "cancelled" || agent.status === "interrupted",
22 ).length;
23 const batches = agents.filter((agent) => agent.kind === "subagent_batch").length;
24 const individuals = agentCount - batches;
25 // Workflow coordinators can keep running between dynamic member launches.
26 const live =
27 coordinatorStatus !== undefined ? !isTerminalSubagentStatus(coordinatorStatus) : working > 0;
28 const subjects = [
29 individuals > 0 ? `${individuals} subagent${individuals === 1 ? "" : "s"}` : null,
30 batches > 0
31 ? `${batches} ${individuals > 0 ? "" : "subagent "}batch${batches === 1 ? "" : "es"}`
32 : null,
33 ]
34 .filter(Boolean)
35 .join(" and ");
36 const lead = `${batches > 0 ? "Launched" : live ? "Kicked off" : "Ran"} ${subjects || "subagents"}`;
37
38 const status = live
39 ? working > 0
40 ? `${working} working`
41 : "working"
42 : coordinatorStatus === "failed"
43 ? "Workflow failed"
44 : coordinatorStatus === "cancelled" || coordinatorStatus === "interrupted"
45 ? "Workflow stopped"
46 : failed > 0
47 ? `${failed} failed`
48 : stopped > 0
49 ? `${stopped} stopped`
50 : idle > 0
51 ? `${idle} idle`
52 : coordinatorStatus !== "completed" &&
53 (agents.length === 0 || agents.length < agentCount)
54 ? "Status unavailable"
55 : "✓ completed";
56 const tone = live
57 ? "working"
58 : failed > 0 || coordinatorStatus === "failed"
59 ? "failed"
60 : status === "✓ completed"
61 ? "completed"
62 : "inactive";
63 return { live, lead, status, tone };
64}
docs/user/providers-antigravity.md+2
...@@ -145,6 +145,8 @@ Subagent launches appear as **Antigravity subagent batch** in **Agents** on web...@@ -145,6 +145,8 @@ Subagent launches appear as **Antigravity subagent batch** in **Agents** on web
145and in the work log on mobile. One launch can start several agents. The batch stays active145and in the work log on mobile. One launch can start several agents. The batch stays active
146after launch while the parent turn runs. When that turn ends, the entry becomes idle and146after launch while the parent turn runs. When that turn ends, the entry becomes idle and
147states that individual agent status is unavailable. Launch errors remain visible.147states that individual agent status is unavailable. Launch errors remain visible.
148The conversation summary counts launches as batches and shows idle or stopped batches
149without a completion checkmark.
148150
149The official ACP agent does not send individual child status, names, models, token usage,151The official ACP agent does not send individual child status, names, models, token usage,
150or reply ownership. T3 Code cannot show separate child entries or separate child replies152or reply ownership. T3 Code cannot show separate child entries or separate child replies
packages/client-runtime/src/state/subagentRuntime.test.ts+13-2
...@@ -69,7 +69,7 @@ describe("foldSubagentActivities", () => {...@@ -69,7 +69,7 @@ describe("foldSubagentActivities", () => {
69 it("shows the batch status limit after its parent turn ends without claiming a result", () => {69 it("shows the batch status limit after its parent turn ends without claiming a result", () => {
70 const running = activity("task.progress", {70 const running = activity("task.progress", {
71 taskId: "batch-1",71 taskId: "batch-1",
72 taskType: "subagent",72 taskType: "subagent_batch",
73 title: "Antigravity subagent batch",73 title: "Antigravity subagent batch",
74 status: "running",74 status: "running",
75 summary: "Launch readers",75 summary: "Launch readers",
...@@ -78,7 +78,7 @@ describe("foldSubagentActivities", () => {...@@ -78,7 +78,7 @@ describe("foldSubagentActivities", () => {
78 running,78 running,
79 activity("task.updated", {79 activity("task.updated", {
80 taskId: "batch-1",80 taskId: "batch-1",
81 taskType: "subagent",81 taskType: "subagent_batch",
82 status: "idle",82 status: "idle",
83 detail: "Turn ended. Individual agent status is unavailable.",83 detail: "Turn ended. Individual agent status is unavailable.",
84 timelineBypass: true,84 timelineBypass: true,
...@@ -87,6 +87,7 @@ describe("foldSubagentActivities", () => {...@@ -87,6 +87,7 @@ describe("foldSubagentActivities", () => {
87 expect(agents).toHaveLength(1);87 expect(agents).toHaveLength(1);
88 expect(agents[0]).toMatchObject({88 expect(agents[0]).toMatchObject({
89 title: "Antigravity subagent batch",89 title: "Antigravity subagent batch",
90 kind: "subagent_batch",
90 status: "idle",91 status: "idle",
91 progress: "Turn ended. Individual agent status is unavailable.",92 progress: "Turn ended. Individual agent status is unavailable.",
92 result: null,93 result: null,
...@@ -94,6 +95,16 @@ describe("foldSubagentActivities", () => {...@@ -94,6 +95,16 @@ describe("foldSubagentActivities", () => {
94 });95 });
95 });96 });
9697
98 it("learns batch identity from a later update and retains it on sparse updates", () => {
99 const agents = fold([
100 activity("task.progress", { taskId: "batch-1", taskType: "subagent", status: "running" }),
101 activity("task.updated", { taskId: "batch-1", taskType: "subagent_batch", status: "idle" }),
102 activity("task.updated", { taskId: "batch-1", status: "idle" }),
103 ]);
104 expect(agents).toHaveLength(1);
105 expect(agents[0]).toMatchObject({ kind: "subagent_batch", status: "idle" });
106 });
107
97 it("builds an agent from start → progress → completion", () => {108 it("builds an agent from start → progress → completion", () => {
98 const agents = fold([109 const agents = fold([
99 activity("task.started", {110 activity("task.started", {
packages/client-runtime/src/state/subagentRuntime.ts+5-1
...@@ -58,7 +58,7 @@ export interface SubagentRunHandles {...@@ -58,7 +58,7 @@ export interface SubagentRunHandles {
5858
59export interface RuntimeSubagent {59export interface RuntimeSubagent {
60 readonly id: string;60 readonly id: string;
61 readonly kind: "subagent" | "workflow" | "workflow_agent";61 readonly kind: "subagent" | "subagent_batch" | "workflow" | "workflow_agent";
62 readonly title: string;62 readonly title: string;
63 readonly role: string | null;63 readonly role: string | null;
64 readonly model: string | null;64 readonly model: string | null;
...@@ -259,6 +259,9 @@ function kindFromPayload(...@@ -259,6 +259,9 @@ function kindFromPayload(
259 payload: Record<string, unknown>,259 payload: Record<string, unknown>,
260 agentId: string,260 agentId: string,
261): RuntimeSubagent["kind"] {261): RuntimeSubagent["kind"] {
262 if (payload.taskType === "subagent_batch") {
263 return "subagent_batch";
264 }
262 if (asString(payload.taskType) === "local_workflow") {265 if (asString(payload.taskType) === "local_workflow") {
263 return "workflow";266 return "workflow";
264 }267 }
...@@ -314,6 +317,7 @@ function getOrCreate(...@@ -314,6 +317,7 @@ function getOrCreate(
314317
315/** Metadata fill from any payload: never downgrades known values to null. */318/** Metadata fill from any payload: never downgrades known values to null. */
316function fillMetadata(agent: MutableAgent, payload: Record<string, unknown>): void {319function fillMetadata(agent: MutableAgent, payload: Record<string, unknown>): void {
320 if (payload.taskType === "subagent_batch") agent.kind = "subagent_batch";
317 const title = asString(payload.title);321 const title = asString(payload.title);
318 if (title) agent.title = title;322 if (title) agent.title = title;
319 const role = asString(payload.role);323 const role = asString(payload.role);