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", () => {
22562256 },
22572257 );
22582258
2259 it.each(["cancelled", "failed", "interrupted"] as const)(
2260 "replaces Antigravity progress with %s without a timeline bypass flag",
2259 it.each(["cancelled", "failed", "interrupted", "idle"] as const)(
2260 "replaces Antigravity batch progress with %s",
22612261 (status) => {
2262 const detail =
2263 status === "idle"
2264 ? "Turn ended. Individual agent status is unavailable."
2265 : "Antigravity process stopped.";
22622266 const thread = makeThread({
22632267 id: ThreadId.make("antigravity-agents"),
22642268 projectId: ProjectId.make("project-1"),
......@@ -2268,14 +2272,14 @@ describe("quiet timeline: nested agents", () => {
22682272 makeActivity({
22692273 id: EventId.make(`progress-${index}`),
22702274 kind: "task.progress",
2271 summary: "Antigravity subagent",
2275 summary: "Antigravity subagent batch",
22722276 createdAt: `2026-04-01T00:00:0${index + 1}.000Z`,
22732277 payload: {
22742278 taskId,
2275 taskType: "subagent",
2279 taskType: "subagent_batch",
22762280 agentKind: "agent",
2277 title: "Antigravity subagent",
2278 detail: "Antigravity subagent",
2281 title: "Antigravity subagent batch",
2282 detail: "Antigravity subagent batch",
22792283 status: "running",
22802284 },
22812285 }),
......@@ -2287,11 +2291,11 @@ describe("quiet timeline: nested agents", () => {
22872291 createdAt: "2026-04-01T00:00:03.000Z",
22882292 payload: {
22892293 taskId: "trajectory:4",
2290 taskType: "subagent",
2294 taskType: "subagent_batch",
22912295 agentKind: "agent",
2292 title: "Antigravity subagent",
2296 title: "Antigravity subagent batch",
22932297 status,
2294 error: "Antigravity process stopped.",
2298 ...(status === "idle" ? { detail, timelineBypass: true } : { error: detail }),
22952299 },
22962300 }),
22972301 ],
......@@ -2302,8 +2306,8 @@ describe("quiet timeline: nested agents", () => {
23022306 expect(rows).toHaveLength(2);
23032307 expect(rows[0]).toMatchObject({
23042308 lifecycleStatus: status === "failed" ? "failed" : "stopped",
2305 detail: "Antigravity process stopped.",
2306 workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent" },
2309 detail,
2310 workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" },
23072311 });
23082312 expect(rows[1]).toMatchObject({
23092313 lifecycleStatus: "inProgress",
apps/mobile/src/lib/threadActivity.ts+5-1
......@@ -449,6 +449,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
449449 const taskDetailAsLabel =
450450 isTaskActivity &&
451451 !taskSummary &&
452 !title &&
452453 typeof payload?.detail === "string" &&
453454 payload.detail.length > 0
454455 ? payload.detail
......@@ -499,7 +500,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
499500 toolName: data?.toolName,
500501 data,
501502 });
502 if (detail && !repeatsCommand) entry.detail = detail;
503 if (detail && detail !== title && !repeatsCommand) entry.detail = detail;
503504 }
504505 if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) {
505506 entry.detail = payload.error;
......@@ -1110,6 +1111,9 @@ function extractWorkLogToolLifecycleStatus(
11101111 payload: Record<string, unknown> | null,
11111112): WorkLogToolLifecycleStatus | undefined {
11121113 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";
11131117 if (status === "pending" || status === "running" || status === "waiting") return "inProgress";
11141118 if (status === "cancelled" || status === "interrupted") return "stopped";
11151119 if (
apps/server/src/provider/Layers/AntigravityAdapter.test.ts+3-2
......@@ -796,6 +796,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {
796796 expect(launched.payload).toMatchObject({
797797 taskId: started.toolCall.toolCallId,
798798 title: "Antigravity subagent batch",
799 taskType: "subagent_batch",
799800 description: "Launch subagents",
800801 status: "running",
801802 });
......@@ -857,7 +858,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {
857858 const completed = yield* h.waitForEvent((event) => event.type === "task.completed");
858859 expect(completed.payload).toEqual({
859860 taskId: "replayed:4",
860 taskType: "subagent",
861 taskType: "subagent_batch",
861862 toolUseId: "replayed:4",
862863 title: "Antigravity subagent batch",
863864 status: "failed",
......@@ -1085,7 +1086,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {
10851086 expect(settled.payload).toMatchObject({
10861087 taskId: "trajectory:4",
10871088 title: "Antigravity subagent batch",
1088 taskType: "subagent",
1089 taskType: "subagent_batch",
10891090 status:
10901091 stop === "disconnect"
10911092 ? "failed"
apps/server/src/provider/Layers/AntigravityAdapter.ts+1-1
......@@ -176,7 +176,7 @@ interface OpenSubagent {
176176function subagentLinkage(toolCallId: string) {
177177 return {
178178 taskId: RuntimeTaskId.make(toolCallId),
179 taskType: "subagent",
179 taskType: "subagent_batch",
180180 toolUseId: toolCallId,
181181 title: "Antigravity subagent batch",
182182 };
apps/web/src/components/AgentsPanel.tsx+4-2
......@@ -140,6 +140,8 @@ function agentActivityText(agent: RuntimeSubagent): string | null {
140140/** Flat, non-interactive agent status line. No unfold. */
141141function AgentRow({ agent }: { agent: RuntimeSubagent }) {
142142 const visuals = STATUS_VISUALS[agent.status];
143 const statusLabel =
144 agent.kind === "subagent_batch" && agent.status === "idle" ? "Idle" : visuals.label;
143145 const activity = agentActivityText(agent);
144146 const modelLabel = formatSubagentModelLabel(agent.model, agent.effort);
145147 const role =
......@@ -180,12 +182,12 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) {
180182 agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground",
181183 )}
182184 >
183 {activity ?? visuals.label}
185 {activity ?? statusLabel}
184186 </span>
185187 <span className="col-start-2 col-end-4 row-start-3 truncate font-mono text-[.7rem] tabular-nums text-muted-foreground/70">
186188 {metadata.join(" · ")}
187189 </span>
188 <span className="sr-only">{visuals.label}</span>
190 <span className="sr-only">{statusLabel}</span>
189191 </div>
190192 );
191193}
apps/web/src/components/chat/MessagesTimeline.tsx+15-32
......@@ -167,6 +167,7 @@ import {
167167 formatInlineTerminalContextLabel,
168168 textContainsInlineTerminalContextLabels,
169169} from "./userMessageTerminalContexts";
170import { deriveAgentSpawnSummary } from "./agentSpawnSummary";
170171import { SkillInlineText } from "./SkillInlineText";
171172import { formatWorkspaceRelativePath } from "../../filePathDisplay";
172173import {
......@@ -2993,22 +2994,12 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
29932994 Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0),
29942995 );
29952996
2996 const running = agents.filter(
2997 (agent) => agent.status === "running" || agent.status === "pending",
2998 ).length;
2999 const waiting = agents.filter((agent) => agent.status === "waiting").length;
3000 const failed = agents.filter((agent) => agent.status === "failed").length;
3001 // The coordinator's own status is authoritative for workflows: dynamic
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;
2997 const summary = deriveAgentSpawnSummary({
2998 agents,
2999 agentCount,
3000 coordinatorStatus: workflowGroup?.workflow.status,
3001 });
3002 const { live, lead } = summary;
30123003 // Same rule as the panel footer: providers may aggregate member usage into
30133004 // the coordinator, so count the coordinator only when no members exist.
30143005 const totalTokens = agents.reduce(
......@@ -3020,22 +3011,14 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
30203011 const workflowName =
30213012 workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null;
30223013
3023 // One steady in-flight presentation (monitoring-pill rule): waiting and
3024 // stalled agents read as working; only settled states differentiate.
3025 const working = running + waiting;
3026 const dotClass = live ? "bg-info" : failed > 0 ? "bg-destructive" : "bg-success";
3027 const lead = live
3028 ? `Kicked off ${agentCount} subagent${agentCount === 1 ? "" : "s"}`
3029 : `Ran ${agentCount} subagent${agentCount === 1 ? "" : "s"}`;
3030 const status = live
3031 ? livePhase
3032 ? `${livePhase.title} · ${livePhase.activeCount} working`
3033 : working > 0
3034 ? `${working} working`
3035 : "working"
3036 : failed > 0
3037 ? `${failed} failed`
3038 : "✓ completed";
3014 const dotClass = {
3015 working: "bg-info",
3016 failed: "bg-destructive",
3017 completed: "bg-success",
3018 inactive: "bg-muted-foreground/50",
3019 }[summary.tone];
3020 const status =
3021 live && livePhase ? `${livePhase.title} · ${livePhase.activeCount} working` : summary.status;
30393022
30403023 return (
30413024 <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
145145and in the work log on mobile. One launch can start several agents. The batch stays active
146146after launch while the parent turn runs. When that turn ends, the entry becomes idle and
147147states 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
149151The official ACP agent does not send individual child status, names, models, token usage,
150152or 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", () => {
6969 it("shows the batch status limit after its parent turn ends without claiming a result", () => {
7070 const running = activity("task.progress", {
7171 taskId: "batch-1",
72 taskType: "subagent",
72 taskType: "subagent_batch",
7373 title: "Antigravity subagent batch",
7474 status: "running",
7575 summary: "Launch readers",
......@@ -78,7 +78,7 @@ describe("foldSubagentActivities", () => {
7878 running,
7979 activity("task.updated", {
8080 taskId: "batch-1",
81 taskType: "subagent",
81 taskType: "subagent_batch",
8282 status: "idle",
8383 detail: "Turn ended. Individual agent status is unavailable.",
8484 timelineBypass: true,
......@@ -87,6 +87,7 @@ describe("foldSubagentActivities", () => {
8787 expect(agents).toHaveLength(1);
8888 expect(agents[0]).toMatchObject({
8989 title: "Antigravity subagent batch",
90 kind: "subagent_batch",
9091 status: "idle",
9192 progress: "Turn ended. Individual agent status is unavailable.",
9293 result: null,
......@@ -94,6 +95,16 @@ describe("foldSubagentActivities", () => {
9495 });
9596 });
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
97108 it("builds an agent from start → progress → completion", () => {
98109 const agents = fold([
99110 activity("task.started", {
packages/client-runtime/src/state/subagentRuntime.ts+5-1
......@@ -58,7 +58,7 @@ export interface SubagentRunHandles {
5858
5959export interface RuntimeSubagent {
6060 readonly id: string;
61 readonly kind: "subagent" | "workflow" | "workflow_agent";
61 readonly kind: "subagent" | "subagent_batch" | "workflow" | "workflow_agent";
6262 readonly title: string;
6363 readonly role: string | null;
6464 readonly model: string | null;
......@@ -259,6 +259,9 @@ function kindFromPayload(
259259 payload: Record<string, unknown>,
260260 agentId: string,
261261): RuntimeSubagent["kind"] {
262 if (payload.taskType === "subagent_batch") {
263 return "subagent_batch";
264 }
262265 if (asString(payload.taskType) === "local_workflow") {
263266 return "workflow";
264267 }
......@@ -314,6 +317,7 @@ function getOrCreate(
314317
315318/** Metadata fill from any payload: never downgrades known values to null. */
316319function fillMetadata(agent: MutableAgent, payload: Record<string, unknown>): void {
320 if (payload.taskType === "subagent_batch") agent.kind = "subagent_batch";
317321 const title = asString(payload.title);
318322 if (title) agent.title = title;
319323 const role = asString(payload.role);