| author | |
| committer | |
| log | f8a14b28ff80a92d8f4df9a9aa53bfacb8195199 |
| tree | 07d71771e7c565a7a2df76f0d6bdf85cbdbd6dfe |
| parent | 3653cb22ffb30bf133fecc30c955941fe267cf1c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 2122 | 2122 | ||
| 2123 | describe("quiet timeline: nested agents", () => { | 2123 | describe("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 | } |
| 323 | 323 | ||
| 324 | /** Codex children settle via task.updated (idle/failed/interrupted), never | 324 | /** Some providers settle agents through task.updated instead of task.completed. */ |
| 325 | * task.completed — these rows are mobile's only terminal signal for them. */ | ||
| 326 | const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([ | 325 | const 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 | ]); |
| 333 | 331 | ||
| 334 | function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean { | 332 | function 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 | } |
| 348 | 346 | ||
| ... | @@ -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 are | 349 | * 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 signal | 350 | * 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 means | 351 | * (a surface that hides rows must keep its own terminal signal). That means |
| 354 | * task.completed (Claude) AND terminal bypassed task.updated (Codex, whose | 352 | * task.completed and terminal task.updated, including Antigravity cancellation. |
| 355 | * children never emit task.completed — review finding). | ||
| 356 | */ | 353 | */ |
| 357 | function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { | 354 | function 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 only | 431 | // 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"; |
| 26 | import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../antigravityAuthSupport.ts"; | 26 | import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../antigravityAuthSupport.ts"; |
| 27 | import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts"; | 27 | import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts"; |
| 28 | import { makeAntigravityAcpRuntime } from "../acp/AntigravityAcpSupport.ts"; | 28 | import { makeAntigravityAcpRuntime } from "../acp/AntigravityAcpSupport.ts"; |
| 29 | import { | ||
| 30 | mergeToolCallState, | ||
| 31 | parseSessionUpdateEvent, | ||
| 32 | type AcpToolCallState, | ||
| 33 | } from "../acp/AcpRuntimeModel.ts"; | ||
| 29 | import { makeAntigravityAdapter, type AntigravityAdapterOptions } from "./AntigravityAdapter.ts"; | 34 | import { makeAntigravityAdapter, type AntigravityAdapterOptions } from "./AntigravityAdapter.ts"; |
| 30 | 35 | ||
| 31 | const instanceId = ProviderInstanceId.make("antigravity-test"); | 36 | const instanceId = ProviderInstanceId.make("antigravity-test"); |
| ... | @@ -49,6 +54,20 @@ interface NativePrompt { | ... | @@ -49,6 +54,20 @@ interface NativePrompt { |
| 49 | 54 | ||
| 50 | type Runtime = Effect.Success<ReturnType<AntigravityAdapterOptions["makeRuntime"]>>; | 55 | type Runtime = Effect.Success<ReturnType<AntigravityAdapterOptions["makeRuntime"]>>; |
| 51 | 56 | ||
| 57 | function 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 | |||
| 52 | const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (options?: { | 71 | const 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 | ); |
| 744 | 763 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 72 | import { | 72 | import { |
| 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 | } |
| 166 | 169 | ||
| 170 | interface OpenSubagent { | ||
| 171 | readonly turnId: TurnId | undefined; | ||
| 172 | readonly status: "pending" | "running" | undefined; | ||
| 173 | } | ||
| 174 | |||
| 175 | function subagentLinkage(toolCallId: string) { | ||
| 176 | return { | ||
| 177 | taskId: RuntimeTaskId.make(toolCallId), | ||
| 178 | taskType: "subagent", | ||
| 179 | toolUseId: toolCallId, | ||
| 180 | title: "Antigravity subagent", | ||
| 181 | }; | ||
| 182 | } | ||
| 183 | |||
| 167 | interface TurnIntent { | 184 | interface 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 | ); |
| 370 | 389 | ||
| 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.stopLock | 417 | 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 |
| 17 | 20 | ||
| 18 | const isSessionNotification = Schema.is(EffectAcpSchema.SessionNotification); | 21 | const isSessionNotification = Schema.is(EffectAcpSchema.SessionNotification); |
| 19 | 22 | ||
| 23 | describe("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 | |||
| 20 | const questionRequest = { | 85 | const 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 |
| 349 | export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean { | 349 | export 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. */ | ||
| 354 | export 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. */ | ||
| 369 | export 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 | |||
| 379 | export 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 |
| 139 | so reverting a thread or editing and resubmitting an earlier turn is unavailable. Send a | 139 | so reverting a thread or editing and resubmitting an earlier turn is unavailable. Send a |
| 140 | follow-up message or start a new thread instead. | 140 | follow-up message or start a new thread instead. |
| 141 | 141 | ||
| 142 | ### Subagents | ||
| 143 | |||
| 144 | Antigravity subagent calls appear in **Agents** on web and desktop, and in the work log on | ||
| 145 | mobile. Each call shows its status and the result or error returned by Antigravity. Calls | ||
| 146 | that run at the same time have separate entries. | ||
| 147 | |||
| 148 | The official ACP agent does not report subagent names, models, token usage, or parent links. | ||
| 149 | Entries use the name **Antigravity subagent**. Child tool calls cannot be assigned to an entry | ||
| 150 | because ACP does not include their owning subagent. These entries track each invocation, | ||
| 151 | not a separate thread you can open or control. | ||
| 152 | |||
| 142 | ## Accounts and removal | 153 | ## Accounts and removal |
| 143 | 154 | ||
| 144 | Each Antigravity provider instance has its own Google sign-in on its environment. Use | 155 | Each Antigravity provider instance has its own Google sign-in on its environment. Use |