authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 17:02:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 17:02:25-07:00
logf8a14b28ff80a92d8f4df9a9aa53bfacb8195199
tree07d71771e7c565a7a2df76f0d6bdf85cbdbd6dfe
parent3653cb22ffb30bf133fecc30c955941fe267cf1c
signature Signed by PGP key B5690EEEBB952194

feat(antigravity): show subagent calls and results (#9515)


7 files changed, 728 insertions(+), 16 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+98
...@@ -2121,6 +2121,104 @@ describe("buildThreadFeed", () => {...@@ -2121,6 +2121,104 @@ describe("buildThreadFeed", () => {
2121});2121});
21222122
2123describe("quiet timeline: nested agents", () => {2123describe("quiet timeline: nested agents", () => {
2124 it.each(["task.updated", "task.progress"] as const)(
2125 "does not mark an ordinary task complete when it resumes through %s",
2126 (resumeKind) => {
2127 const thread = makeThread({
2128 id: ThreadId.make("resumed-agent"),
2129 projectId: ProjectId.make("project-1"),
2130 title: "Resumed agent",
2131 activities: (
2132 [
2133 ["task.progress", "running", "Review"],
2134 ["task.updated", "idle", "Task idle"],
2135 [resumeKind, "running", "Review resumed"],
2136 ] as const
2137 ).map(([kind, status, summary], index) =>
2138 makeActivity({
2139 id: EventId.make(`resumed-${index}`),
2140 kind,
2141 summary,
2142 createdAt: `2026-04-01T00:00:0${index + 1}.000Z`,
2143 payload: {
2144 taskId: "agent-1",
2145 agentKind: "agent",
2146 title: "Reviewer",
2147 status,
2148 detail: summary,
2149 },
2150 }),
2151 ),
2152 });
2153 const rows = buildThreadFeed(thread).flatMap((entry) =>
2154 entry.type === "activity-group" ? entry.activities : [],
2155 );
2156 expect(rows).toMatchObject([
2157 {
2158 lifecycleStatus: "inProgress",
2159 summary: "Reviewer",
2160 workEntry: { label: resumeKind === "task.progress" ? "Review resumed" : "Review" },
2161 },
2162 ]);
2163 },
2164 );
2165
2166 it.each(["cancelled", "failed", "interrupted"] as const)(
2167 "replaces Antigravity progress with %s without a timeline bypass flag",
2168 (status) => {
2169 const thread = makeThread({
2170 id: ThreadId.make("antigravity-agents"),
2171 projectId: ProjectId.make("project-1"),
2172 title: "Antigravity subagents",
2173 activities: [
2174 ...["trajectory:4", "trajectory:5"].map((taskId, index) =>
2175 makeActivity({
2176 id: EventId.make(`progress-${index}`),
2177 kind: "task.progress",
2178 summary: "Antigravity subagent",
2179 createdAt: `2026-04-01T00:00:0${index + 1}.000Z`,
2180 payload: {
2181 taskId,
2182 taskType: "subagent",
2183 agentKind: "agent",
2184 title: "Antigravity subagent",
2185 detail: "Antigravity subagent",
2186 status: "running",
2187 },
2188 }),
2189 ),
2190 makeActivity({
2191 id: EventId.make("agent-stopped"),
2192 kind: "task.updated",
2193 summary: `Task ${status}`,
2194 createdAt: "2026-04-01T00:00:03.000Z",
2195 payload: {
2196 taskId: "trajectory:4",
2197 taskType: "subagent",
2198 agentKind: "agent",
2199 title: "Antigravity subagent",
2200 status,
2201 error: "Antigravity process stopped.",
2202 },
2203 }),
2204 ],
2205 });
2206 const rows = buildThreadFeed(thread).flatMap((entry) =>
2207 entry.type === "activity-group" ? entry.activities : [],
2208 );
2209 expect(rows).toHaveLength(2);
2210 expect(rows[0]).toMatchObject({
2211 lifecycleStatus: status === "failed" ? "failed" : "stopped",
2212 detail: "Antigravity process stopped.",
2213 workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent" },
2214 });
2215 expect(rows[1]).toMatchObject({
2216 lifecycleStatus: "inProgress",
2217 workEntry: { taskId: "trajectory:5" },
2218 });
2219 },
2220 );
2221
2124 it("keeps a nested agent's terminal row but hides its background work", () => {2222 it("keeps a nested agent's terminal row but hides its background work", () => {
2125 const thread = makeThread({2223 const thread = makeThread({
2126 id: ThreadId.make("thread-nested"),2224 id: ThreadId.make("thread-nested"),
apps/mobile/src/lib/threadActivity.ts+14-15
...@@ -321,17 +321,15 @@ function resolvePendingUserInputAnswer(...@@ -321,17 +321,15 @@ function resolvePendingUserInputAnswer(
321 return selectedOptionValues[0] ?? null;321 return selectedOptionValues[0] ?? null;
322}322}
323323
324/** Codex children settle via task.updated (idle/failed/interrupted), never324/** Some providers settle agents through task.updated instead of task.completed. */
325 * task.completed — these rows are mobile's only terminal signal for them. */
326const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([325const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([
327 "idle",
328 "completed",326 "completed",
329 "failed",327 "failed",
330 "cancelled",328 "cancelled",
331 "interrupted",329 "interrupted",
332]);330]);
333331
334function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean {332function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean {
335 if (activity.kind !== "task.updated") {333 if (activity.kind !== "task.updated") {
336 return false;334 return false;
337 }335 }
...@@ -340,9 +338,9 @@ function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean...@@ -340,9 +338,9 @@ function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean
340 ? (activity.payload as Record<string, unknown>)338 ? (activity.payload as Record<string, unknown>)
341 : null;339 : null;
342 return (340 return (
343 payload?.timelineBypass === true &&341 typeof payload?.status === "string" &&
344 typeof payload.status === "string" &&342 (MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) ||
345 MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status)343 (payload.timelineBypass === true && payload.status === "idle"))
346 );344 );
347}345}
348346
...@@ -351,8 +349,7 @@ function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean...@@ -351,8 +349,7 @@ function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean
351 * activity lives in the Agents sheet, not the work log. Terminal rows are349 * activity lives in the Agents sheet, not the work log. Terminal rows are
352 * kept — with no Agents surface on mobile they are the terminal signal350 * kept — with no Agents surface on mobile they are the terminal signal
353 * (a surface that hides rows must keep its own terminal signal). That means351 * (a surface that hides rows must keep its own terminal signal). That means
354 * task.completed (Claude) AND terminal bypassed task.updated (Codex, whose352 * task.completed and terminal task.updated, including Antigravity cancellation.
355 * children never emit task.completed — review finding).
356 */353 */
357function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {354function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
358 const payload =355 const payload =
...@@ -362,7 +359,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean...@@ -362,7 +359,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
362 if (!payload) {359 if (!payload) {
363 return false;360 return false;
364 }361 }
365 const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalBypassUpdate(activity);362 const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity);
366 if (payload.timelineBypass === true && !isTerminalTaskRow) {363 if (payload.timelineBypass === true && !isTerminalTaskRow) {
367 return true;364 return true;
368 }365 }
...@@ -387,8 +384,7 @@ function deriveWorkLogEntries(...@@ -387,8 +384,7 @@ function deriveWorkLogEntries(
387 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;384 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
388 if (activity.kind === "tool.started") continue;385 if (activity.kind === "tool.started") continue;
389 if (activity.kind === "task.started") continue;386 if (activity.kind === "task.started") continue;
390 // Terminal bypassed updates pass: Codex children's only terminal signal.387 if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue;
391 if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue;
392 if (activity.kind === "tool.progress") continue;388 if (activity.kind === "tool.progress") continue;
393 if (activity.kind === "context-window.updated") continue;389 if (activity.kind === "context-window.updated") continue;
394 if (activity.summary === "Checkpoint captured") continue;390 if (activity.summary === "Checkpoint captured") continue;
...@@ -432,9 +428,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -432,9 +428,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
432 const changedFiles = extractChangedFiles(payload);428 const changedFiles = extractChangedFiles(payload);
433 const title = extractToolTitle(payload);429 const title = extractToolTitle(payload);
434 const toolPresentation = extractToolActivityPresentation(payload);430 const toolPresentation = extractToolActivityPresentation(payload);
435 // task.updated included: terminal bypassed updates (Codex children's only431 // Terminal task updates carry identity so they replace each child's progress row.
436 // terminal signal) must carry task identity so they collapse per child
437 // instead of stacking anonymous "Task idle" rows.
438 const isTaskActivity =432 const isTaskActivity =
439 activity.kind === "task.progress" ||433 activity.kind === "task.progress" ||
440 activity.kind === "task.completed" ||434 activity.kind === "task.completed" ||
...@@ -498,6 +492,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -498,6 +492,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
498 });492 });
499 if (detail && !repeatsCommand) entry.detail = detail;493 if (detail && !repeatsCommand) entry.detail = detail;
500 }494 }
495 if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) {
496 entry.detail = payload.error;
497 }
501 if (viewedImagePath) {498 if (viewedImagePath) {
502 entry.viewedImagePath = viewedImagePath;499 entry.viewedImagePath = viewedImagePath;
503 }500 }
...@@ -1099,6 +1096,8 @@ function extractWorkLogToolLifecycleStatus(...@@ -1099,6 +1096,8 @@ function extractWorkLogToolLifecycleStatus(
1099 payload: Record<string, unknown> | null,1096 payload: Record<string, unknown> | null,
1100): WorkLogToolLifecycleStatus | undefined {1097): WorkLogToolLifecycleStatus | undefined {
1101 const status = payload?.status;1098 const status = payload?.status;
1099 if (status === "pending" || status === "running" || status === "waiting") return "inProgress";
1100 if (status === "cancelled" || status === "interrupted") return "stopped";
1102 if (1101 if (
1103 status === "inProgress" ||1102 status === "inProgress" ||
1104 status === "completed" ||1103 status === "completed" ||
apps/server/src/provider/Layers/AntigravityAdapter.test.ts+395
...@@ -26,6 +26,11 @@ import { ServerConfig } from "../../config.ts";...@@ -26,6 +26,11 @@ import { ServerConfig } from "../../config.ts";
26import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../antigravityAuthSupport.ts";26import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../antigravityAuthSupport.ts";
27import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts";27import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts";
28import { makeAntigravityAcpRuntime } from "../acp/AntigravityAcpSupport.ts";28import { makeAntigravityAcpRuntime } from "../acp/AntigravityAcpSupport.ts";
29import {
30 mergeToolCallState,
31 parseSessionUpdateEvent,
32 type AcpToolCallState,
33} from "../acp/AcpRuntimeModel.ts";
29import { makeAntigravityAdapter, type AntigravityAdapterOptions } from "./AntigravityAdapter.ts";34import { makeAntigravityAdapter, type AntigravityAdapterOptions } from "./AntigravityAdapter.ts";
3035
31const instanceId = ProviderInstanceId.make("antigravity-test");36const instanceId = ProviderInstanceId.make("antigravity-test");
...@@ -49,6 +54,20 @@ interface NativePrompt {...@@ -49,6 +54,20 @@ interface NativePrompt {
4954
50type Runtime = Effect.Success<ReturnType<AntigravityAdapterOptions["makeRuntime"]>>;55type Runtime = Effect.Success<ReturnType<AntigravityAdapterOptions["makeRuntime"]>>;
5156
57function nativeToolUpdate(
58 update: Extract<
59 AcpSchema.SessionNotification["update"],
60 { sessionUpdate: "tool_call" | "tool_call_update" }
61 >,
62 previous?: AcpToolCallState,
63) {
64 const event = parseSessionUpdateEvent({ sessionId: nativeSessionId, update }).events.find(
65 (event) => event._tag === "ToolCallUpdated",
66 );
67 if (!event) throw new Error("Expected a native tool update");
68 return { ...event, toolCall: mergeToolCallState(previous, event.toolCall) };
69}
70
52const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (options?: {71const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (options?: {
53 readonly enabled?: boolean;72 readonly enabled?: boolean;
54 readonly holdCancel?: boolean;73 readonly holdCancel?: boolean;
...@@ -742,6 +761,382 @@ it.layer(layer)("AntigravityAdapter", (it) => {...@@ -742,6 +761,382 @@ it.layer(layer)("AntigravityAdapter", (it) => {
742 }),761 }),
743 );762 );
744763
764 it.effect(
765 "shows concurrent native subagent calls and their results without inventing metadata",
766 () =>
767 Effect.gen(function* () {
768 const h = yield* makeHarness();
769 yield* h.adapter.startSession({
770 threadId,
771 cwd: process.cwd(),
772 runtimeMode: "approval-required",
773 });
774 const sending = yield* h.adapter
775 .sendTurn({ threadId, input: "Review with subagents" })
776 .pipe(Effect.forkChild);
777 const prompt = yield* h.nextPrompt;
778 for (const id of ["trajectory:4", "trajectory:5"]) {
779 yield* h.emitNative(
780 nativeToolUpdate({
781 sessionUpdate: "tool_call",
782 toolCallId: id,
783 title: "Running start_subagent",
784 kind: "other",
785 status: "in_progress",
786 rawInput: {},
787 }),
788 );
789 const running = yield* h.waitForEvent((event) => event.type === "task.progress");
790 expect(running.payload).toEqual({
791 taskId: id,
792 taskType: "subagent",
793 toolUseId: id,
794 title: "Antigravity subagent",
795 description: "Antigravity subagent",
796 status: "running",
797 });
798 yield* h.emitNative(
799 nativeToolUpdate({
800 sessionUpdate: "tool_call_update",
801 toolCallId: id,
802 status: "in_progress",
803 }),
804 );
805 }
806 for (const [id, status, result] of [
807 ["trajectory:4", "completed", "No defects found."],
808 ["trajectory:5", "failed", "Subagent exceeded its limit."],
809 ] as const) {
810 yield* h.emitNative(
811 nativeToolUpdate({
812 sessionUpdate: "tool_call_update",
813 toolCallId: id,
814 status,
815 rawOutput: result,
816 }),
817 );
818 const completed = yield* h.waitForEvent((event) => event.type === "task.completed");
819 expect(completed.payload).toEqual({
820 taskId: id,
821 taskType: "subagent",
822 toolUseId: id,
823 title: "Antigravity subagent",
824 status,
825 summary: result,
826 });
827 }
828 yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" });
829 const turn = yield* Fiber.join(sending);
830 yield* h.waitForEvent((event) => event.type === "turn.completed");
831 expect(
832 h.seen
833 .filter((event) => event.type.startsWith("task."))
834 .every((event) => event.turnId === turn.turnId),
835 ).toBe(true);
836 expect(h.seen.filter((event) => event.type.startsWith("item."))).toHaveLength(0);
837 expect(h.seen.filter((event) => event.type === "task.progress")).toHaveLength(2);
838 }),
839 );
840
841 it.effect("waits for a replayed subagent's final status and result", () =>
842 Effect.gen(function* () {
843 const h = yield* makeHarness();
844 yield* h.adapter.startSession({
845 threadId,
846 cwd: process.cwd(),
847 runtimeMode: "approval-required",
848 });
849 // ACP history announces a completed tool first, even when its result failed.
850 yield* h.emitNative(
851 nativeToolUpdate({
852 sessionUpdate: "tool_call",
853 toolCallId: "replayed:4",
854 title: "Running start_subagent",
855 kind: "other",
856 status: "completed",
857 rawInput: "{}",
858 }),
859 );
860 yield* h.emitNative(
861 nativeToolUpdate({
862 sessionUpdate: "tool_call_update",
863 toolCallId: "replayed:4",
864 status: "failed",
865 rawOutput: "Review failed.",
866 }),
867 );
868 const completed = yield* h.waitForEvent((event) => event.type === "task.completed");
869 expect(completed.payload).toEqual({
870 taskId: "replayed:4",
871 taskType: "subagent",
872 toolUseId: "replayed:4",
873 title: "Antigravity subagent",
874 status: "failed",
875 summary: "Review failed.",
876 });
877 expect(h.seen.filter((event) => event.type.startsWith("task."))).toHaveLength(1);
878 }),
879 );
880
881 it.effect("completes a live subagent delivered in one tool call", () =>
882 Effect.gen(function* () {
883 const h = yield* makeHarness();
884 yield* h.adapter.startSession({
885 threadId,
886 cwd: process.cwd(),
887 runtimeMode: "approval-required",
888 });
889 const sending = yield* h.adapter
890 .sendTurn({ threadId, input: "Review with subagents" })
891 .pipe(Effect.forkChild);
892 const prompt = yield* h.nextPrompt;
893 for (const [id, rawOutput] of [
894 ["live:4", "Review complete."],
895 ["live:5", undefined],
896 ] as const) {
897 yield* h.emitNative(
898 nativeToolUpdate({
899 sessionUpdate: "tool_call",
900 toolCallId: id,
901 title: "Running start_subagent",
902 kind: "other",
903 status: "completed",
904 rawInput: {},
905 ...(rawOutput ? { rawOutput } : {}),
906 }),
907 );
908 const completed = yield* h.waitForEvent((event) => event.type === "task.completed");
909 expect(completed.payload).toMatchObject({ taskId: id, status: "completed" });
910 expect(completed.payload.summary).toBe(rawOutput);
911 }
912 yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" });
913 yield* Fiber.join(sending);
914 yield* h.waitForEvent((event) => event.type === "turn.completed");
915 expect(h.seen.filter((event) => event.type === "task.updated")).toHaveLength(0);
916 }),
917 );
918
919 for (const settlement of ["cancelled", "interrupted", "completed"] as const) {
920 it.effect(`does not reopen a ${settlement} subagent on late merged updates`, () =>
921 Effect.gen(function* () {
922 const h = yield* makeHarness();
923 yield* h.adapter.startSession({
924 threadId,
925 cwd: process.cwd(),
926 runtimeMode: "approval-required",
927 });
928 const first = yield* h.adapter
929 .sendTurn({ threadId, input: "Start review" })
930 .pipe(Effect.forkChild);
931 const firstPrompt = yield* h.nextPrompt;
932 const started = nativeToolUpdate({
933 sessionUpdate: "tool_call",
934 toolCallId: "old:4",
935 title: "Running start_subagent",
936 kind: "other",
937 status: "in_progress",
938 rawInput: {},
939 });
940 yield* h.emitNative(started);
941 yield* h.waitForEvent((event) => event.type === "task.progress");
942 if (settlement === "cancelled") {
943 yield* h.adapter.interruptTurn(threadId);
944 } else {
945 if (settlement === "completed") {
946 yield* h.emitNative(
947 nativeToolUpdate(
948 {
949 sessionUpdate: "tool_call_update",
950 toolCallId: "old:4",
951 status: "completed",
952 rawOutput: "Original result.",
953 },
954 started.toolCall,
955 ),
956 );
957 }
958 yield* Deferred.succeed(firstPrompt.result, { stopReason: "end_turn" });
959 }
960 yield* Fiber.join(first);
961 yield* h.waitForEvent((event) => event.type === "turn.completed");
962 const second = yield* h.adapter
963 .sendTurn({ threadId, input: "Next review" })
964 .pipe(Effect.forkChild);
965 const secondPrompt = yield* h.nextPrompt;
966 for (const status of ["in_progress", "completed"] as const) {
967 yield* h.emitNative(
968 nativeToolUpdate(
969 {
970 sessionUpdate: "tool_call_update",
971 toolCallId: "old:4",
972 status,
973 rawOutput: "Late result.",
974 },
975 started.toolCall,
976 ),
977 );
978 }
979 yield* h.emitNative(
980 nativeToolUpdate({
981 sessionUpdate: "tool_call",
982 toolCallId: "new:4",
983 title: "Running start_subagent",
984 kind: "other",
985 status: "completed",
986 rawOutput: "New result.",
987 }),
988 );
989 const completed = yield* h.waitForEvent((event) => event.type === "task.completed");
990 expect(completed.payload.taskId).toBe("new:4");
991 yield* Deferred.succeed(secondPrompt.result, { stopReason: "end_turn" });
992 yield* Fiber.join(second);
993 yield* h.waitForEvent((event) => event.type === "turn.completed");
994 expect(h.seen.filter((event) => event.type === "task.progress")).toHaveLength(1);
995 expect(
996 h.seen.filter(
997 (event) => event.type === "task.completed" && event.payload.taskId === "old:4",
998 ),
999 ).toHaveLength(settlement === "completed" ? 1 : 0);
1000 }),
1001 );
1002 }
1003
1004 it.effect("keeps MCP identity when later updates omit metadata", () =>
1005 Effect.gen(function* () {
1006 const h = yield* makeHarness();
1007 yield* h.adapter.startSession({
1008 threadId,
1009 cwd: process.cwd(),
1010 runtimeMode: "approval-required",
1011 });
1012 const sending = yield* h.adapter
1013 .sendTurn({ threadId, input: "Run an MCP tool" })
1014 .pipe(Effect.forkChild);
1015 const prompt = yield* h.nextPrompt;
1016 const started = nativeToolUpdate({
1017 sessionUpdate: "tool_call",
1018 toolCallId: "mcp-4",
1019 title: "Running start_subagent",
1020 kind: "other",
1021 status: "in_progress",
1022 rawInput: { arguments: {} },
1023 _meta: { is_mcp_tool_call: true },
1024 });
1025 yield* h.emitNative(started);
1026 for (const status of ["in_progress", "completed"] as const) {
1027 yield* h.emitNative(
1028 nativeToolUpdate(
1029 {
1030 sessionUpdate: "tool_call_update",
1031 toolCallId: "mcp-4",
1032 status,
1033 rawOutput: "MCP output.",
1034 },
1035 started.toolCall,
1036 ),
1037 );
1038 }
1039 yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" });
1040 yield* Fiber.join(sending);
1041 yield* h.waitForEvent((event) => event.type === "turn.completed");
1042 expect(h.seen.filter((event) => event.type.startsWith("task."))).toHaveLength(0);
1043 expect(h.seen.filter((event) => event.type === "item.updated")).toHaveLength(2);
1044 expect(h.seen.filter((event) => event.type === "item.completed")).toHaveLength(1);
1045 }),
1046 );
1047
1048 it.effect("shows pending subagents and closes a denied invocation", () =>
1049 Effect.gen(function* () {
1050 const h = yield* makeHarness();
1051 yield* h.adapter.startSession({
1052 threadId,
1053 cwd: process.cwd(),
1054 runtimeMode: "approval-required",
1055 });
1056 yield* h.emitNative(
1057 nativeToolUpdate({
1058 sessionUpdate: "tool_call",
1059 toolCallId: "permission-1",
1060 title: "Run start_subagent?",
1061 kind: "other",
1062 status: "pending",
1063 rawInput: {},
1064 }),
1065 );
1066 const pending = yield* h.waitForEvent((event) => event.type === "task.progress");
1067 expect(pending.payload.status).toBe("pending");
1068 yield* h.emitNative(
1069 nativeToolUpdate({
1070 sessionUpdate: "tool_call_update",
1071 toolCallId: "permission-1",
1072 status: "failed",
1073 }),
1074 );
1075 const completed = yield* h.waitForEvent((event) => event.type === "task.completed");
1076 expect(completed.payload.status).toBe("failed");
1077 }),
1078 );
1079
1080 for (const stop of ["cancel", "steer", "disconnect", "end_turn"] as const) {
1081 it.effect(`settles open subagent calls on ${stop}`, () =>
1082 Effect.gen(function* () {
1083 const h = yield* makeHarness();
1084 yield* h.adapter.startSession({
1085 threadId,
1086 cwd: process.cwd(),
1087 runtimeMode: "approval-required",
1088 });
1089 const sending = yield* h.adapter
1090 .sendTurn({ threadId, input: "Start a subagent" })
1091 .pipe(Effect.forkChild);
1092 const prompt = yield* h.nextPrompt;
1093 yield* h.emitNative(
1094 nativeToolUpdate({
1095 sessionUpdate: "tool_call",
1096 toolCallId: "trajectory:4",
1097 title: "Running start_subagent",
1098 kind: "other",
1099 status: "in_progress",
1100 rawInput: {},
1101 }),
1102 );
1103 yield* h.waitForEvent((event) => event.type === "task.progress");
1104 if (stop === "disconnect") {
1105 yield* h.emitNative({
1106 _tag: "ConnectionTerminated",
1107 error: new AcpErrors.AcpTransportError({ detail: "Process exited.", cause: undefined }),
1108 });
1109 } else if (stop === "cancel") {
1110 yield* h.adapter.interruptTurn(threadId);
1111 } else if (stop === "steer") {
1112 const steering = yield* h.adapter
1113 .sendTurn({ threadId, input: "Change direction" })
1114 .pipe(Effect.forkChild);
1115 const replacement = yield* h.nextPrompt;
1116 yield* Deferred.succeed(replacement.result, { stopReason: "end_turn" });
1117 yield* Fiber.join(steering);
1118 } else {
1119 yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" });
1120 }
1121 const settled = yield* h.waitForEvent((event) => event.type === "task.updated");
1122 expect(settled.payload).toMatchObject({
1123 taskId: "trajectory:4",
1124 title: "Antigravity subagent",
1125 taskType: "subagent",
1126 status:
1127 stop === "disconnect"
1128 ? "failed"
1129 : stop === "cancel" || stop === "steer"
1130 ? "cancelled"
1131 : "interrupted",
1132 });
1133 if (stop === "disconnect")
1134 yield* h.waitForEvent((event) => event.type === "session.exited");
1135 else yield* Fiber.join(sending);
1136 }),
1137 );
1138 }
1139
745 it.effect("retires a prompt cancelled before native dispatch", () =>1140 it.effect("retires a prompt cancelled before native dispatch", () =>
746 Effect.gen(function* () {1141 Effect.gen(function* () {
747 const h = yield* makeHarness({ holdDispatch: true });1142 const h = yield* makeHarness({ holdDispatch: true });
apps/server/src/provider/Layers/AntigravityAdapter.ts+114-1
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import {1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 ApprovalRequestId,2 ApprovalRequestId,
4 EventId,3 EventId,
5 ProviderDriverKind,4 ProviderDriverKind,
...@@ -13,6 +12,7 @@ import {...@@ -13,6 +12,7 @@ import {
13 type ProviderSession,12 type ProviderSession,
14 type ProviderSetupError,13 type ProviderSetupError,
15 type ProviderUserInputAnswers,14 type ProviderUserInputAnswers,
15 type RuntimeTaskStatus,
16 type ThreadId,16 type ThreadId,
17 type TurnCompletedPayload,17 type TurnCompletedPayload,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
...@@ -71,8 +71,11 @@ import {...@@ -71,8 +71,11 @@ import {
71} from "../acp/AntigravityAcpSupport.ts";71} from "../acp/AntigravityAcpSupport.ts";
72import {72import {
73 antigravityApprovalOptions,73 antigravityApprovalOptions,
74 antigravitySubagentResult,
75 classifyAntigravitySubagentToolCall,
74 extractAntigravityUserInputQuestion,76 extractAntigravityUserInputQuestion,
75 isAntigravityOpenCommand,77 isAntigravityOpenCommand,
78 isAntigravitySubagentReplayStart,
76 isAntigravityUserInputRequest,79 isAntigravityUserInputRequest,
77 makeAntigravityUserInputResponse,80 makeAntigravityUserInputResponse,
78 normalizeAntigravityToolCall,81 normalizeAntigravityToolCall,
...@@ -164,6 +167,20 @@ interface OpenCommand {...@@ -164,6 +167,20 @@ interface OpenCommand {
164 readonly promoted: boolean;167 readonly promoted: boolean;
165}168}
166169
170interface OpenSubagent {
171 readonly turnId: TurnId | undefined;
172 readonly status: "pending" | "running" | undefined;
173}
174
175function subagentLinkage(toolCallId: string) {
176 return {
177 taskId: RuntimeTaskId.make(toolCallId),
178 taskType: "subagent",
179 toolUseId: toolCallId,
180 title: "Antigravity subagent",
181 };
182}
183
167interface TurnIntent {184interface TurnIntent {
168 readonly turnId: TurnId;185 readonly turnId: TurnId;
169 readonly generation: number;186 readonly generation: number;
...@@ -182,6 +199,8 @@ interface SessionContext {...@@ -182,6 +199,8 @@ interface SessionContext {
182 readonly approvals: Map<ApprovalRequestId, PendingApproval>;199 readonly approvals: Map<ApprovalRequestId, PendingApproval>;
183 readonly questions: Map<ApprovalRequestId, PendingQuestion>;200 readonly questions: Map<ApprovalRequestId, PendingQuestion>;
184 readonly commands: Map<string, OpenCommand>;201 readonly commands: Map<string, OpenCommand>;
202 /** Keep only IDs after settlement or MCP exclusion so merged late updates cannot change identity. */
203 readonly subagents: Map<string, OpenSubagent | "finished" | "mcp">;
185 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;204 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
186 session: ProviderSession;205 session: ProviderSession;
187 activeTurnId: TurnId | undefined;206 activeTurnId: TurnId | undefined;
...@@ -368,6 +387,32 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi...@@ -368,6 +387,32 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
368 }),387 }),
369 );388 );
370389
390 const finishSubagents = (
391 context: SessionContext,
392 status: Extract<RuntimeTaskStatus, "cancelled" | "failed" | "interrupted">,
393 error?: string,
394 ) =>
395 context.commandLock.withPermit(
396 Effect.gen(function* () {
397 for (const [id, subagent] of context.subagents) {
398 if (subagent === "finished" || subagent === "mcp") continue;
399 yield* emit({
400 type: "task.updated",
401 ...(yield* stamp),
402 provider: PROVIDER,
403 threadId: context.threadId,
404 turnId: subagent.turnId,
405 payload: {
406 ...subagentLinkage(id),
407 status,
408 ...(error ? { error } : {}),
409 },
410 });
411 context.subagents.set(id, "finished");
412 }
413 }),
414 );
415
371 const stopContext = (context: SessionContext) =>416 const stopContext = (context: SessionContext) =>
372 context.stopLock417 context.stopLock
373 .withPermit(418 .withPermit(
...@@ -383,6 +428,12 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi...@@ -383,6 +428,12 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
383 context.closed = true;428 context.closed = true;
384 if (sessions.get(context.threadId) === context) sessions.delete(context.threadId);429 if (sessions.get(context.threadId) === context) sessions.delete(context.threadId);
385 yield* finishBackgroundCommands(context);430 yield* finishBackgroundCommands(context);
431 yield* finishSubagents(
432 context,
433 context.disconnected ? "failed" : "cancelled",
434 context.disconnected ? "Antigravity process stopped." : undefined,
435 );
436 context.subagents.clear();
386 yield* emit({437 yield* emit({
387 type: "session.exited",438 type: "session.exited",
388 ...(yield* stamp),439 ...(yield* stamp),
...@@ -560,6 +611,54 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi...@@ -560,6 +611,54 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
560 yield* context.commandLock.withPermit(611 yield* context.commandLock.withPermit(
561 Effect.gen(function* () {612 Effect.gen(function* () {
562 const toolCall = normalizeAntigravityToolCall(event.toolCall);613 const toolCall = normalizeAntigravityToolCall(event.toolCall);
614 const tracked = context.subagents.get(toolCall.toolCallId);
615 if (tracked === "finished") return;
616 const kind = classifyAntigravitySubagentToolCall(toolCall, event.rawPayload);
617 const isMcp = tracked === "mcp" || kind === "mcp";
618 if (isMcp) context.subagents.set(toolCall.toolCallId, "mcp");
619 const subagent = tracked === "mcp" ? undefined : tracked;
620 if (!isMcp && (subagent || kind === "subagent")) {
621 const turnId = subagent?.turnId ?? context.activeTurnId;
622 const linkage = subagentLinkage(toolCall.toolCallId);
623 // Replay starts claim completion before the result says whether the call failed.
624 if (
625 context.activeTurnId === undefined &&
626 isAntigravitySubagentReplayStart(event.rawPayload)
627 ) {
628 context.subagents.set(toolCall.toolCallId, { turnId, status: undefined });
629 return;
630 }
631 if (toolCall.status === "completed" || toolCall.status === "failed") {
632 const summary = antigravitySubagentResult(toolCall);
633 yield* emit({
634 type: "task.completed",
635 ...(yield* stamp),
636 provider: PROVIDER,
637 threadId: context.threadId,
638 turnId,
639 payload: {
640 ...linkage,
641 status: toolCall.status,
642 ...(summary ? { summary } : {}),
643 },
644 });
645 context.subagents.set(toolCall.toolCallId, "finished");
646 } else {
647 const status = toolCall.status === "pending" ? "pending" : "running";
648 if (subagent?.status !== status) {
649 yield* emit({
650 type: "task.progress",
651 ...(yield* stamp),
652 provider: PROVIDER,
653 threadId: context.threadId,
654 turnId,
655 payload: { ...linkage, description: linkage.title, status },
656 });
657 }
658 context.subagents.set(toolCall.toolCallId, { turnId, status });
659 }
660 return;
661 }
563 const existing = context.commands.get(toolCall.toolCallId);662 const existing = context.commands.get(toolCall.toolCallId);
564 yield* emit(663 yield* emit(
565 makeAcpToolCallEvent({664 makeAcpToolCallEvent({
...@@ -745,6 +844,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi...@@ -745,6 +844,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
745 approvals: new Map(),844 approvals: new Map(),
746 questions: new Map(),845 questions: new Map(),
747 commands: new Map(),846 commands: new Map(),
847 subagents: new Map(),
748 turns: [],848 turns: [],
749 session,849 session,
750 activeTurnId: undefined,850 activeTurnId: undefined,
...@@ -866,6 +966,18 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi...@@ -866,6 +966,18 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
866 if (turn.settled || context.stopped || context.generation !== turn.generation) return;966 if (turn.settled || context.stopped || context.generation !== turn.generation) return;
867 turn.settled = true;967 turn.settled = true;
868 yield* promoteBackgroundCommands(context);968 yield* promoteBackgroundCommands(context);
969 yield* finishSubagents(
970 context,
971 payload.state === "cancelled"
972 ? "cancelled"
973 : payload.state === "failed"
974 ? "failed"
975 : "interrupted",
976 payload.errorMessage ??
977 (payload.state === "completed"
978 ? "Antigravity ended the turn before reporting a subagent result."
979 : undefined),
980 );
869 context.activeTurnId = undefined;981 context.activeTurnId = undefined;
870 context.promptFiber = undefined;982 context.promptFiber = undefined;
871 context.session = {983 context.session = {
...@@ -923,6 +1035,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi...@@ -923,6 +1035,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
923 yield* cancelRequests(context);1035 yield* cancelRequests(context);
924 yield* context.runtime.cancel;1036 yield* context.runtime.cancel;
925 yield* Fiber.await(context.promptFiber);1037 yield* Fiber.await(context.promptFiber);
1038 yield* finishSubagents(context, "cancelled");
926 }1039 }
927 yield* applyAntigravityAcpModelSelection({1040 yield* applyAntigravityAcpModelSelection({
928 runtime: context.runtime,1041 runtime: context.runtime,
apps/server/src/provider/acp/AntigravityProtocol.test.ts+65
...@@ -6,6 +6,9 @@ import {...@@ -6,6 +6,9 @@ import {
6 extractAntigravityUserInputQuestion,6 extractAntigravityUserInputQuestion,
7 isAntigravityOpenCommand,7 isAntigravityOpenCommand,
8 antigravityApprovalOptions,8 antigravityApprovalOptions,
9 antigravitySubagentResult,
10 isAntigravitySubagentReplayStart,
11 classifyAntigravitySubagentToolCall,
9 isAntigravityUserInputRequest,12 isAntigravityUserInputRequest,
10 makeAntigravityUserInputResponse,13 makeAntigravityUserInputResponse,
11 normalizeAntigravitySessionUpdate,14 normalizeAntigravitySessionUpdate,
...@@ -17,6 +20,68 @@ import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.t...@@ -17,6 +20,68 @@ import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.t
1720
18const isSessionNotification = Schema.is(EffectAcpSchema.SessionNotification);21const isSessionNotification = Schema.is(EffectAcpSchema.SessionNotification);
1922
23describe("native Antigravity subagent tools", () => {
24 it("recognizes only native invocation titles and excludes MCP tools", () => {
25 const toolCall = { toolCallId: "trajectory:4", kind: "other", data: {} };
26 for (const title of ["Running start_subagent", "Run start_subagent?"]) {
27 expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBe("subagent");
28 expect(
29 classifyAntigravitySubagentToolCall(
30 { ...toolCall, title },
31 { update: { _meta: { is_mcp_tool_call: true } } },
32 ),
33 ).toBe("mcp");
34 }
35 for (const title of [
36 "Running subagent",
37 "start_subagent",
38 "Run command",
39 "Running manage_task",
40 ]) {
41 expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBeUndefined();
42 }
43 expect(
44 classifyAntigravitySubagentToolCall(
45 { ...toolCall, title: "Running start_subagent", kind: "execute" },
46 {},
47 ),
48 ).toBeUndefined();
49 });
50
51 it("recognizes history starts and bounds the native result", () => {
52 expect(
53 isAntigravitySubagentReplayStart({
54 update: { sessionUpdate: "tool_call", status: "completed", rawOutput: "Done." },
55 }),
56 ).toBe(false);
57 expect(
58 isAntigravitySubagentReplayStart({
59 update: { sessionUpdate: "tool_call", status: "completed" },
60 }),
61 ).toBe(true);
62 expect(
63 isAntigravitySubagentReplayStart({
64 update: { sessionUpdate: "tool_call_update", status: "completed" },
65 }),
66 ).toBe(false);
67 expect(
68 antigravitySubagentResult({
69 toolCallId: "trajectory:4",
70 data: { rawOutput: " Finished review. " },
71 }),
72 ).toBe("Finished review.");
73 const result = antigravitySubagentResult({
74 toolCallId: "trajectory:4",
75 data: { rawOutput: `${"x".repeat(16_000)}The result.` },
76 });
77 expect(result?.length).toBeLessThan(8_100);
78 expect(result?.endsWith("The result.")).toBe(true);
79 expect(
80 antigravitySubagentResult({ toolCallId: "trajectory:4", data: { rawOutput: {} } }),
81 ).toBeUndefined();
82 });
83});
84
20const questionRequest = {85const questionRequest = {
21 sessionId: "session-1",86 sessionId: "session-1",
22 toolCall: {87 toolCall: {
apps/server/src/provider/acp/AntigravityProtocol.ts+31
...@@ -349,3 +349,34 @@ export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToo...@@ -349,3 +349,34 @@ export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToo
349export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean {349export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean {
350 return toolCall.kind === "execute" && toolCall.status === "inProgress";350 return toolCall.kind === "execute" && toolCall.status === "inProgress";
351}351}
352
353/** ACP 1.1.1 exposes subagent invocations as ordinary tools, without child IDs or models. */
354export function classifyAntigravitySubagentToolCall(
355 toolCall: AcpToolCallState,
356 rawPayload: unknown,
357): "subagent" | "mcp" | undefined {
358 if (
359 (toolCall.kind !== undefined && toolCall.kind !== "other") ||
360 (toolCall.title !== "Running start_subagent" && toolCall.title !== "Run start_subagent?")
361 )
362 return undefined;
363 const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined;
364 const meta = Predicate.isObject(update) ? update._meta : undefined;
365 return Predicate.isObject(meta) && meta.is_mcp_tool_call === true ? "mcp" : "subagent";
366}
367
368/** History sends a completed start before the separate result and its final status. */
369export function isAntigravitySubagentReplayStart(rawPayload: unknown): boolean {
370 const update = Predicate.isObject(rawPayload) ? rawPayload.update : undefined;
371 return (
372 Predicate.isObject(update) &&
373 update.sessionUpdate === "tool_call" &&
374 update.status === "completed" &&
375 (update.rawOutput === undefined || update.rawOutput === null)
376 );
377}
378
379export function antigravitySubagentResult(toolCall: AcpToolCallState): string | undefined {
380 const output = toolCall.data.rawOutput;
381 return typeof output === "string" && output.trim() ? boundText(output.trim()) : undefined;
382}
docs/user/providers-antigravity.md+11
...@@ -139,6 +139,17 @@ T3 Code keeps thread history and file diffs. Antigravity does not support conver...@@ -139,6 +139,17 @@ T3 Code keeps thread history and file diffs. Antigravity does not support conver
139so reverting a thread or editing and resubmitting an earlier turn is unavailable. Send a139so reverting a thread or editing and resubmitting an earlier turn is unavailable. Send a
140follow-up message or start a new thread instead.140follow-up message or start a new thread instead.
141141
142### Subagents
143
144Antigravity subagent calls appear in **Agents** on web and desktop, and in the work log on
145mobile. Each call shows its status and the result or error returned by Antigravity. Calls
146that run at the same time have separate entries.
147
148The official ACP agent does not report subagent names, models, token usage, or parent links.
149Entries use the name **Antigravity subagent**. Child tool calls cannot be assigned to an entry
150because ACP does not include their owning subagent. These entries track each invocation,
151not a separate thread you can open or control.
152
142## Accounts and removal153## Accounts and removal
143154
144Each Antigravity provider instance has its own Google sign-in on its environment. Use155Each Antigravity provider instance has its own Google sign-in on its environment. Use