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", () => {
21212121});
21222122
21232123describe("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
21242222 it("keeps a nested agent's terminal row but hides its background work", () => {
21252223 const thread = makeThread({
21262224 id: ThreadId.make("thread-nested"),
apps/mobile/src/lib/threadActivity.ts+14-15
......@@ -321,17 +321,15 @@ function resolvePendingUserInputAnswer(
321321 return selectedOptionValues[0] ?? null;
322322}
323323
324/** Codex children settle via task.updated (idle/failed/interrupted), never
325 * task.completed — these rows are mobile's only terminal signal for them. */
324/** Some providers settle agents through task.updated instead of task.completed. */
326325const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([
327 "idle",
328326 "completed",
329327 "failed",
330328 "cancelled",
331329 "interrupted",
332330]);
333331
334function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean {
332function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean {
335333 if (activity.kind !== "task.updated") {
336334 return false;
337335 }
......@@ -340,9 +338,9 @@ function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean
340338 ? (activity.payload as Record<string, unknown>)
341339 : null;
342340 return (
343 payload?.timelineBypass === true &&
344 typeof payload.status === "string" &&
345 MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status)
341 typeof payload?.status === "string" &&
342 (MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status) ||
343 (payload.timelineBypass === true && payload.status === "idle"))
346344 );
347345}
348346
......@@ -351,8 +349,7 @@ function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean
351349 * activity lives in the Agents sheet, not the work log. Terminal rows are
352350 * kept — with no Agents surface on mobile they are the terminal signal
353351 * (a surface that hides rows must keep its own terminal signal). That means
354 * task.completed (Claude) AND terminal bypassed task.updated (Codex, whose
355 * children never emit task.completed — review finding).
352 * task.completed and terminal task.updated, including Antigravity cancellation.
356353 */
357354function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
358355 const payload =
......@@ -362,7 +359,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean
362359 if (!payload) {
363360 return false;
364361 }
365 const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalBypassUpdate(activity);
362 const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity);
366363 if (payload.timelineBypass === true && !isTerminalTaskRow) {
367364 return true;
368365 }
......@@ -387,8 +384,7 @@ function deriveWorkLogEntries(
387384 if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue;
388385 if (activity.kind === "tool.started") continue;
389386 if (activity.kind === "task.started") continue;
390 // Terminal bypassed updates pass: Codex children's only terminal signal.
391 if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue;
387 if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue;
392388 if (activity.kind === "tool.progress") continue;
393389 if (activity.kind === "context-window.updated") continue;
394390 if (activity.summary === "Checkpoint captured") continue;
......@@ -432,9 +428,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
432428 const changedFiles = extractChangedFiles(payload);
433429 const title = extractToolTitle(payload);
434430 const toolPresentation = extractToolActivityPresentation(payload);
435 // task.updated included: terminal bypassed updates (Codex children's only
436 // terminal signal) must carry task identity so they collapse per child
437 // instead of stacking anonymous "Task idle" rows.
431 // Terminal task updates carry identity so they replace each child's progress row.
438432 const isTaskActivity =
439433 activity.kind === "task.progress" ||
440434 activity.kind === "task.completed" ||
......@@ -498,6 +492,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
498492 });
499493 if (detail && !repeatsCommand) entry.detail = detail;
500494 }
495 if (isTaskActivity && typeof payload?.error === "string" && payload.error.trim()) {
496 entry.detail = payload.error;
497 }
501498 if (viewedImagePath) {
502499 entry.viewedImagePath = viewedImagePath;
503500 }
......@@ -1099,6 +1096,8 @@ function extractWorkLogToolLifecycleStatus(
10991096 payload: Record<string, unknown> | null,
11001097): WorkLogToolLifecycleStatus | undefined {
11011098 const status = payload?.status;
1099 if (status === "pending" || status === "running" || status === "waiting") return "inProgress";
1100 if (status === "cancelled" || status === "interrupted") return "stopped";
11021101 if (
11031102 status === "inProgress" ||
11041103 status === "completed" ||
apps/server/src/provider/Layers/AntigravityAdapter.test.ts+395
......@@ -26,6 +26,11 @@ import { ServerConfig } from "../../config.ts";
2626import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../antigravityAuthSupport.ts";
2727import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts";
2828import { makeAntigravityAcpRuntime } from "../acp/AntigravityAcpSupport.ts";
29import {
30 mergeToolCallState,
31 parseSessionUpdateEvent,
32 type AcpToolCallState,
33} from "../acp/AcpRuntimeModel.ts";
2934import { makeAntigravityAdapter, type AntigravityAdapterOptions } from "./AntigravityAdapter.ts";
3035
3136const instanceId = ProviderInstanceId.make("antigravity-test");
......@@ -49,6 +54,20 @@ interface NativePrompt {
4954
5055type 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
5271const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (options?: {
5372 readonly enabled?: boolean;
5473 readonly holdCancel?: boolean;
......@@ -742,6 +761,382 @@ it.layer(layer)("AntigravityAdapter", (it) => {
742761 }),
743762 );
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
7451140 it.effect("retires a prompt cancelled before native dispatch", () =>
7461141 Effect.gen(function* () {
7471142 const h = yield* makeHarness({ holdDispatch: true });
apps/server/src/provider/Layers/AntigravityAdapter.ts+114-1
......@@ -1,5 +1,4 @@
11import {
2 ANTIGRAVITY_DEFAULT_MODEL,
32 ApprovalRequestId,
43 EventId,
54 ProviderDriverKind,
......@@ -13,6 +12,7 @@ import {
1312 type ProviderSession,
1413 type ProviderSetupError,
1514 type ProviderUserInputAnswers,
15 type RuntimeTaskStatus,
1616 type ThreadId,
1717 type TurnCompletedPayload,
1818} from "@t3tools/contracts";
......@@ -71,8 +71,11 @@ import {
7171} from "../acp/AntigravityAcpSupport.ts";
7272import {
7373 antigravityApprovalOptions,
74 antigravitySubagentResult,
75 classifyAntigravitySubagentToolCall,
7476 extractAntigravityUserInputQuestion,
7577 isAntigravityOpenCommand,
78 isAntigravitySubagentReplayStart,
7679 isAntigravityUserInputRequest,
7780 makeAntigravityUserInputResponse,
7881 normalizeAntigravityToolCall,
......@@ -164,6 +167,20 @@ interface OpenCommand {
164167 readonly promoted: boolean;
165168}
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
167184interface TurnIntent {
168185 readonly turnId: TurnId;
169186 readonly generation: number;
......@@ -182,6 +199,8 @@ interface SessionContext {
182199 readonly approvals: Map<ApprovalRequestId, PendingApproval>;
183200 readonly questions: Map<ApprovalRequestId, PendingQuestion>;
184201 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">;
185204 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
186205 session: ProviderSession;
187206 activeTurnId: TurnId | undefined;
......@@ -368,6 +387,32 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
368387 }),
369388 );
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
371416 const stopContext = (context: SessionContext) =>
372417 context.stopLock
373418 .withPermit(
......@@ -383,6 +428,12 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
383428 context.closed = true;
384429 if (sessions.get(context.threadId) === context) sessions.delete(context.threadId);
385430 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();
386437 yield* emit({
387438 type: "session.exited",
388439 ...(yield* stamp),
......@@ -560,6 +611,54 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
560611 yield* context.commandLock.withPermit(
561612 Effect.gen(function* () {
562613 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 }
563662 const existing = context.commands.get(toolCall.toolCallId);
564663 yield* emit(
565664 makeAcpToolCallEvent({
......@@ -745,6 +844,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
745844 approvals: new Map(),
746845 questions: new Map(),
747846 commands: new Map(),
847 subagents: new Map(),
748848 turns: [],
749849 session,
750850 activeTurnId: undefined,
......@@ -866,6 +966,18 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
866966 if (turn.settled || context.stopped || context.generation !== turn.generation) return;
867967 turn.settled = true;
868968 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 );
869981 context.activeTurnId = undefined;
870982 context.promptFiber = undefined;
871983 context.session = {
......@@ -923,6 +1035,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
9231035 yield* cancelRequests(context);
9241036 yield* context.runtime.cancel;
9251037 yield* Fiber.await(context.promptFiber);
1038 yield* finishSubagents(context, "cancelled");
9261039 }
9271040 yield* applyAntigravityAcpModelSelection({
9281041 runtime: context.runtime,
apps/server/src/provider/acp/AntigravityProtocol.test.ts+65
......@@ -6,6 +6,9 @@ import {
66 extractAntigravityUserInputQuestion,
77 isAntigravityOpenCommand,
88 antigravityApprovalOptions,
9 antigravitySubagentResult,
10 isAntigravitySubagentReplayStart,
11 classifyAntigravitySubagentToolCall,
912 isAntigravityUserInputRequest,
1013 makeAntigravityUserInputResponse,
1114 normalizeAntigravitySessionUpdate,
......@@ -17,6 +20,68 @@ import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.t
1720
1821const 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
2085const questionRequest = {
2186 sessionId: "session-1",
2287 toolCall: {
apps/server/src/provider/acp/AntigravityProtocol.ts+31
......@@ -349,3 +349,34 @@ export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToo
349349export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean {
350350 return toolCall.kind === "execute" && toolCall.status === "inProgress";
351351}
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
139139so reverting a thread or editing and resubmitting an earlier turn is unavailable. Send a
140140follow-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
142153## Accounts and removal
143154
144155Each Antigravity provider instance has its own Google sign-in on its environment. Use