1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
3import type * as EffectAcpSchema from "effect-acp/compat";
4import * as AcpWireSchema from "effect-acp/schema-v1";
5
6import {
7 extractAntigravityUserInputQuestion,
8 isAntigravityOpenCommand,
9 antigravityApprovalOptions,
10 antigravitySubagentOutput,
11 isAntigravitySubagentReplayStart,
12 classifyAntigravitySubagentToolCall,
13 isAntigravityUserInputRequest,
14 makeAntigravityUserInputResponse,
15 normalizeAntigravitySessionUpdate,
16 normalizeAntigravityToolCall,
17 sanitizeAntigravityToolPayload,
18 selectAntigravityPermissionOptionId,
19} from "./AntigravityProtocol.ts";
20import {
21 mergeToolCallState,
22 parseSessionUpdateEvent,
23} from "@t3tools/provider-acp/server/runtimeModel";
24
25const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification);
26
27describe("native Antigravity subagent tools", () => {
28 it("recognizes only native invocation titles and excludes MCP tools", () => {
29 const toolCall = { toolCallId: "trajectory:4", kind: "other", data: {} };
30 for (const title of ["Running start_subagent", "Run start_subagent?"]) {
31 expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBe("subagent");
32 expect(
33 classifyAntigravitySubagentToolCall(
34 { ...toolCall, title },
35 { update: { _meta: { is_mcp_tool_call: true } } },
36 ),
37 ).toBe("mcp");
38 }
39 for (const title of [
40 "Running subagent",
41 "start_subagent",
42 "Run command",
43 "Running manage_task",
44 ]) {
45 expect(classifyAntigravitySubagentToolCall({ ...toolCall, title }, {})).toBeUndefined();
46 }
47 expect(
48 classifyAntigravitySubagentToolCall(
49 { ...toolCall, title: "Running start_subagent", kind: "execute" },
50 {},
51 ),
52 ).toBeUndefined();
53 });
54
55 it("recognizes history starts and bounds the launch output", () => {
56 expect(
57 isAntigravitySubagentReplayStart({
58 update: { sessionUpdate: "tool_call", status: "completed", rawOutput: "Done." },
59 }),
60 ).toBe(false);
61 expect(
62 isAntigravitySubagentReplayStart({
63 update: { sessionUpdate: "tool_call", status: "completed" },
64 }),
65 ).toBe(true);
66 expect(
67 isAntigravitySubagentReplayStart({
68 update: { sessionUpdate: "tool_call_update", status: "completed" },
69 }),
70 ).toBe(false);
71 expect(
72 antigravitySubagentOutput({
73 toolCallId: "trajectory:4",
74 data: { rawOutput: " Finished review. " },
75 }),
76 ).toBe("Finished review.");
77 const result = antigravitySubagentOutput({
78 toolCallId: "trajectory:4",
79 data: { rawOutput: `${"x".repeat(16_000)}The result.` },
80 });
81 expect(result?.length).toBeLessThan(8_100);
82 expect(result?.endsWith("The result.")).toBe(true);
83 expect(
84 antigravitySubagentOutput({ toolCallId: "trajectory:4", data: { rawOutput: {} } }),
85 ).toBeUndefined();
86 });
87});
88
89const questionRequest = {
90 sessionId: "session-1",
91 toolCall: {
92 toolCallId: "interaction_9960062f",
93 status: "pending",
94 title: "Which result label should be used for the verification?",
95 rawInput: {},
96 },
97 options: [
98 { optionId: "1", name: "Verified", kind: "allow_once" },
99 { optionId: "2", name: "Needs review", kind: "allow_once" },
100 ],
101} satisfies EffectAcpSchema.RequestPermissionRequest;
102
103const commandStarted = {
104 sessionId: "session-1",
105 update: {
106 sessionUpdate: "tool_call",
107 toolCallId: "fc28d0af6ad14be8bbba20f4258d4d3e",
108 title: "run_command",
109 kind: "execute",
110 status: "in_progress",
111 rawInput: { CommandLine: "cat probe.txt", Cwd: "/workspace" },
112 },
113} satisfies EffectAcpSchema.SessionNotification;
114
115const commandCompleted = {
116 sessionId: "session-1",
117 update: {
118 toolCallId: commandStarted.update.toolCallId,
119 status: "completed",
120 rawOutput: {
121 commandLine: "cat probe.txt",
122 workingDir: "/workspace",
123 exitCode: 0,
124 exit_code: 0,
125 combinedOutput: "after\n",
126 formatted_output: "after\n",
127 },
128 sessionUpdate: "tool_call_update",
129 },
130} satisfies EffectAcpSchema.SessionNotification;
131
132function parseToolUpdate(notification: EffectAcpSchema.SessionNotification) {
133 const result = parseSessionUpdateEvent(normalizeAntigravitySessionUpdate(notification));
134 const event = result.events[0];
135 if (event?._tag !== "ToolCallUpdated") {
136 throw new Error("Expected a tool update.");
137 }
138 return event;
139}
140
141describe("Antigravity permissions and questions", () => {
142 const permissionRequest = {
143 sessionId: "session-1",
144 toolCall: { toolCallId: "command-1", kind: "execute", title: "Run command" },
145 options: [
146 { optionId: "remember-this-command", name: "Allow always", kind: "allow_always" },
147 { optionId: "run-this-time", name: "Allow", kind: "allow_once" },
148 { optionId: "stop-this-command", name: "Deny", kind: "reject_once" },
149 ],
150 } satisfies EffectAcpSchema.RequestPermissionRequest;
151
152 it("uses only the option IDs offered for each approval decision", () => {
153 expect(selectAntigravityPermissionOptionId(permissionRequest, "accept")).toBe("run-this-time");
154 expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptForSession")).toBe(
155 "remember-this-command",
156 );
157 expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptAlways")).toBe(
158 "remember-this-command",
159 );
160 expect(selectAntigravityPermissionOptionId(permissionRequest, "decline")).toBe(
161 "stop-this-command",
162 );
163 expect(selectAntigravityPermissionOptionId(permissionRequest, "cancel")).toBeUndefined();
164 });
165
166 it("surfaces the agent's prompt injection warning on the remembered approval", () => {
167 const risky = {
168 ...permissionRequest,
169 options: [
170 {
171 optionId: "remember-this-command",
172 name: "Allow Always (risky)",
173 kind: "allow_always",
174 _meta: {
175 "agy.security.warning": {
176 severity: "high",
177 risk: "prompt_injection",
178 title: "Allowing always can be risky",
179 message: "Untrusted files could re-run this action without asking.",
180 },
181 },
182 },
183 { optionId: "run-this-time", name: "Allow", kind: "allow_once" },
184 { optionId: "stop-this-command", name: "Deny", kind: "reject_once" },
185 ],
186 } satisfies EffectAcpSchema.RequestPermissionRequest;
187 expect(antigravityApprovalOptions(risky)).toEqual([
188 { decision: "accept", label: "Allow once" },
189 {
190 decision: "acceptForSession",
191 label: "Allow for this thread",
192 warning: "Untrusted files could re-run this action without asking.",
193 },
194 { decision: "decline", label: "Deny" },
195 { decision: "cancel", label: "Cancel" },
196 ]);
197 expect(antigravityApprovalOptions(permissionRequest)[1]).not.toHaveProperty("warning");
198 });
199
200 it("does not replace unsupported remembered approval with a single approval", () => {
201 const request = {
202 ...permissionRequest,
203 options: permissionRequest.options.filter((option) => option.kind !== "allow_always"),
204 };
205 expect(selectAntigravityPermissionOptionId(request, "acceptAlways")).toBeUndefined();
206 expect(selectAntigravityPermissionOptionId(request, "acceptForSession")).toBeUndefined();
207 expect(selectAntigravityPermissionOptionId(request, "accept")).toBe("run-this-time");
208 expect(
209 selectAntigravityPermissionOptionId({ ...request, options: [] }, "decline"),
210 ).toBeUndefined();
211 });
212
213 it("routes the captured native question to single-choice input, not approval", () => {
214 expect(isAntigravityUserInputRequest(questionRequest)).toBe(true);
215 expect(selectAntigravityPermissionOptionId(questionRequest, "accept")).toBeUndefined();
216 expect(extractAntigravityUserInputQuestion(questionRequest)).toEqual({
217 id: "interaction_9960062f",
218 header: "Question",
219 question: "Which result label should be used for the verification?",
220 multiSelect: false,
221 allowCustomAnswer: false,
222 options: [
223 { value: "1", label: "Verified", description: "Verified" },
224 { value: "2", label: "Needs review", description: "Needs review" },
225 ],
226 });
227 expect(extractAntigravityUserInputQuestion(permissionRequest)).toBeUndefined();
228 });
229
230 it("returns exact opaque IDs and accepts a unique label from an older client", () => {
231 for (const answer of ["1", ["1"], "Verified"]) {
232 expect(
233 makeAntigravityUserInputResponse(questionRequest, { interaction_9960062f: answer }),
234 ).toEqual({ outcome: { outcome: "selected", optionId: "1" } });
235 }
236 const request = {
237 ...questionRequest,
238 options: [{ optionId: " choice: opaque ", name: "Keep", kind: "allow_once" as const }],
239 };
240 expect(
241 makeAntigravityUserInputResponse(request, { interaction_9960062f: " choice: opaque " }),
242 ).toEqual({ outcome: { outcome: "selected", optionId: " choice: opaque " } });
243 });
244
245 it("does not treat a question's reject choice as cancellation", () => {
246 const request = {
247 ...questionRequest,
248 options: [{ optionId: "deny", name: "Do not trust", kind: "reject_once" as const }],
249 };
250 expect(makeAntigravityUserInputResponse(request, { interaction_9960062f: "deny" })).toEqual({
251 outcome: { outcome: "selected", optionId: "deny" },
252 });
253 });
254
255 it("preserves duplicate labels and rejects ambiguous label answers", () => {
256 const request = {
257 ...questionRequest,
258 options: questionRequest.options.map((option) => ({ ...option, name: "Same label" })),
259 };
260 expect(
261 extractAntigravityUserInputQuestion(request)?.options.map((option) => option.value),
262 ).toEqual(["1", "2"]);
263 expect(
264 makeAntigravityUserInputResponse(request, { interaction_9960062f: "Same label" }),
265 ).toBeUndefined();
266 expect(makeAntigravityUserInputResponse(request, { interaction_9960062f: "2" })).toEqual({
267 outcome: { outcome: "selected", optionId: "2" },
268 });
269 });
270
271 it.each([undefined, null, "", "arbitrary answer", [], ["1", "2"], { answer: "1" }, 1])(
272 "keeps the question open for an unsupported answer: %j",
273 (answer) => {
274 expect(
275 makeAntigravityUserInputResponse(questionRequest, { interaction_9960062f: answer }),
276 ).toBeUndefined();
277 },
278 );
279
280 it("rejects missing or duplicate native option IDs", () => {
281 for (const optionId of ["", "1"]) {
282 const request = {
283 ...questionRequest,
284 options: [questionRequest.options[0]!, { ...questionRequest.options[1]!, optionId }],
285 };
286 expect(extractAntigravityUserInputQuestion(request)).toBeUndefined();
287 expect(
288 makeAntigravityUserInputResponse(request, { interaction_9960062f: "1" }),
289 ).toBeUndefined();
290 }
291 });
292
293 it("bounds question text without changing choice IDs", () => {
294 const request = {
295 ...questionRequest,
296 toolCall: { ...questionRequest.toolCall, title: "Question ".repeat(2_000) },
297 options: [
298 { optionId: "stable-choice", name: "Choice ".repeat(2_000), kind: "allow_once" as const },
299 ],
300 };
301 const question = extractAntigravityUserInputQuestion(request);
302 expect(question?.question.length).toBeLessThanOrEqual(8_000);
303 expect(question?.options[0]?.label.length).toBeLessThanOrEqual(512);
304 expect(question?.options[0]?.value).toBe("stable-choice");
305 });
306});
307
308describe("Antigravity tool results", () => {
309 it("normalizes the captured command and completed output for the existing clients", () => {
310 const initial = parseToolUpdate(commandStarted).toolCall;
311 const completed = parseToolUpdate(commandCompleted).toolCall;
312 const toolCall = normalizeAntigravityToolCall(mergeToolCallState(initial, completed));
313
314 expect(toolCall).toMatchObject({
315 kind: "execute",
316 status: "completed",
317 command: "cat probe.txt",
318 detail: "cat probe.txt",
319 data: {
320 command: "cat probe.txt",
321 cwd: "/workspace",
322 item: {
323 command: "cat probe.txt",
324 cwd: "/workspace",
325 aggregatedOutput: "after\n",
326 exitCode: 0,
327 },
328 },
329 });
330 expect(toolCall.data.rawOutput).not.toHaveProperty("formatted_output");
331 expect(commandCompleted.update.rawOutput.formatted_output).toBe("after\n");
332 });
333
334 it.each([
335 { CommandLine: "pwd", Cwd: "/one" },
336 { CommandLine: "pwd", WorkingDirectory: "/one" },
337 { command_line: "pwd", working_dir: "/one" },
338 { commandLine: "pwd", workingDir: "/one" },
339 { command: "pwd", cwd: "/one" },
340 ])("handles native command input aliases: %j", (rawInput) => {
341 const event = parseToolUpdate({
342 ...commandStarted,
343 update: { ...commandStarted.update, rawInput },
344 });
345 expect(normalizeAntigravityToolCall(event.toolCall)).toMatchObject({
346 command: "pwd",
347 detail: "pwd",
348 data: { item: { command: "pwd", cwd: "/one" } },
349 });
350 });
351
352 it("recovers command fields from history and keeps nonzero exits separate from tool failure", () => {
353 const event = parseToolUpdate({
354 ...commandCompleted,
355 update: {
356 ...commandCompleted.update,
357 rawOutput: {
358 command_line: "test -f missing.txt",
359 working_dir: "/workspace",
360 combined_output: "",
361 exit_code: 1,
362 },
363 },
364 });
365 expect(normalizeAntigravityToolCall(event.toolCall)).toMatchObject({
366 kind: "execute",
367 status: "completed",
368 command: "test -f missing.txt",
369 data: { item: { cwd: "/workspace", aggregatedOutput: "", exitCode: 1 } },
370 });
371 });
372
373 it("bounds both canonical output and retained raw output", () => {
374 const output = `${"output line\n".repeat(20_000)}last line\n`;
375 const event = parseToolUpdate({
376 ...commandCompleted,
377 update: {
378 ...commandCompleted.update,
379 rawOutput: {
380 ...commandCompleted.update.rawOutput,
381 combinedOutput: output,
382 formatted_output: output,
383 },
384 },
385 });
386 const toolCall = normalizeAntigravityToolCall(event.toolCall);
387 expect(toolCall.data).toMatchObject({
388 item: { aggregatedOutput: expect.stringContaining("last line\n") },
389 rawOutput: { combinedOutput: expect.stringContaining("last line\n") },
390 });
391 expect(JSON.stringify(toolCall).length).toBeLessThan(20_000);
392 expect(JSON.stringify(event.rawPayload).length).toBeLessThan(10_000);
393 expect(JSON.stringify(event.rawPayload)).not.toContain("formatted_output");
394 });
395
396 it("removes inline images while preserving valid tool content and the local image path", () => {
397 const inlineImage = "inline-image-bytes".repeat(50_000);
398 const notification = {
399 sessionId: "session-1",
400 update: {
401 sessionUpdate: "tool_call_update",
402 toolCallId: "image-1",
403 status: "completed",
404 rawOutput: { imageName: "result", imagePath: "file:///workspace/brain/result%20image.png" },
405 content: [
406 { type: "content", content: { type: "image", mimeType: "image/png", data: inlineImage } },
407 { type: "content", content: { type: "text", text: "Saved the image." } },
408 { type: "diff", path: "/workspace/note.txt", oldText: "old", newText: "new" },
409 ],
410 },
411 } satisfies EffectAcpSchema.SessionNotification;
412 const normalized = normalizeAntigravitySessionUpdate(notification);
413 expect(isSessionNotification(normalized)).toBe(true);
414 expect(JSON.stringify(normalized)).not.toContain("inline-image-bytes");
415 expect(normalized.update).toMatchObject({
416 content: [
417 { type: "content", content: { type: "text", text: "Saved the image." } },
418 { type: "diff", path: "/workspace/note.txt", oldText: "old", newText: "new" },
419 ],
420 });
421 expect(normalizeAntigravityToolCall(parseToolUpdate(normalized).toolCall).data.imagePath).toBe(
422 "/workspace/brain/result image.png",
423 );
424 });
425
426 it.each([
427 ["/workspace/result.png", "/workspace/result.png"],
428 ["C:\\work\\result.png", "C:\\work\\result.png"],
429 ["file:///C:/work/result.png", "C:/work/result.png"],
430 ["https://example.com/result.png", undefined],
431 ["file://another-host/result.png", undefined],
432 ["data:image/png;base64,AAAA", undefined],
433 ])("only promotes local image references: %s", (imagePath, expected) => {
434 const toolCall = normalizeAntigravityToolCall({
435 toolCallId: "image-1",
436 data: { rawOutput: { imagePath } },
437 });
438 expect(toolCall.data.imagePath).toBe(expected);
439 });
440
441 it("bounds nested tool data and drops image blobs and data URLs", () => {
442 const payload = sanitizeAntigravityToolPayload({
443 rawOutput: {
444 text: "a".repeat(100_000),
445 result: { mimeType: "image/png", blob: "image-blob".repeat(100_000) },
446 image: { type: "image", data: "inline-image".repeat(100_000) },
447 uri: "data:image/png;base64,inline-data-url",
448 },
449 });
450 const serialized = JSON.stringify(payload);
451 expect(serialized.length).toBeLessThan(9_000);
452 expect(serialized).not.toContain("image-blob");
453 expect(serialized).not.toContain("inline-image");
454 expect(serialized).not.toContain("inline-data-url");
455 });
456
457 it("keeps large assistant replies and startup command metadata unchanged", () => {
458 const notifications = [
459 {
460 sessionId: "session-1",
461 update: {
462 sessionUpdate: "agent_message_chunk",
463 content: { type: "text", text: "a".repeat(20_000) },
464 },
465 },
466 {
467 sessionId: "session-1",
468 update: {
469 sessionUpdate: "available_commands_update",
470 availableCommands: [{ name: "plan", description: "Make a plan" }],
471 },
472 },
473 ] satisfies ReadonlyArray<EffectAcpSchema.SessionNotification>;
474 for (const notification of notifications) {
475 expect(normalizeAntigravitySessionUpdate(notification)).toBe(notification);
476 }
477 });
478
479 it("tracks only commands that have passed approval and are still running", () => {
480 const running = normalizeAntigravityToolCall(parseToolUpdate(commandStarted).toolCall);
481 expect(isAntigravityOpenCommand(running)).toBe(true);
482 expect(isAntigravityOpenCommand({ ...running, status: "pending" })).toBe(false);
483 expect(isAntigravityOpenCommand({ ...running, kind: "read" })).toBe(false);
484 const completed = normalizeAntigravityToolCall(
485 mergeToolCallState(running, parseToolUpdate(commandCompleted).toolCall),
486 );
487 expect(isAntigravityOpenCommand(completed)).toBe(false);
488 });
489});