| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 4 | import * as AcpWireSchema from "effect-acp/schema-v1"; |
| 5 | |
| 6 | import { |
| 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"; |
| 20 | import { |
| 21 | mergeToolCallState, |
| 22 | parseSessionUpdateEvent, |
| 23 | } from "@t3tools/provider-acp/server/runtimeModel"; |
| 24 | |
| 25 | const isSessionNotification = Schema.is(AcpWireSchema.SessionNotification); |
| 26 | |
| 27 | describe("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 | |
| 89 | const 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 | |
| 103 | const 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 | |
| 115 | const 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 | |
| 132 | function 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 | |
| 141 | describe("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 | |
| 308 | describe("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 | }); |