1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import {
4 ANTIGRAVITY_DEFAULT_MODEL,
5 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
6 type ChatAttachment,
7 type RuntimeMode,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
12import * as EffectAcpErrors from "effect-acp/errors";
13import type * as EffectAcpSchema from "effect-acp/compat";
14
15import { resolveAttachmentPath } from "../../attachmentStore.ts";
16import {
17 antigravityPermissionMode,
18 applyAntigravityAcpModelSelection,
19 buildAntigravityPrompt,
20} from "./AntigravityAcpSupport.ts";
21
22const modelConfig = {
23 id: "model",
24 name: "Model",
25 type: "select",
26 currentValue: "gemini-default",
27 options: [
28 { value: "gemini-default", name: "Gemini default" },
29 { value: "gemini-saved", name: "Gemini saved" },
30 ],
31} satisfies EffectAcpSchema.SessionConfigOption;
32
33function makeModelRuntime(
34 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> = [modelConfig],
35 failure?: EffectAcpErrors.AcpError,
36) {
37 const selections: string[] = [];
38 const setModel = Effect.fn("AntigravityAcpSupportTest.setModel")(function* (model: string) {
39 if (failure) return yield* failure;
40 selections.push(model);
41 });
42 return {
43 runtime: { getConfigOptions: Effect.succeed(configOptions), setModel },
44 selections,
45 };
46}
47
48describe("applyAntigravityAcpModelSelection", () => {
49 it.effect("restores the saved model instead of the cold-resume default", () =>
50 Effect.gen(function* () {
51 const { runtime, selections } = makeModelRuntime();
52 const model = yield* applyAntigravityAcpModelSelection({
53 runtime,
54 model: "gemini-saved",
55 mapError: (cause) => cause,
56 });
57
58 expect(model).toBe("gemini-saved");
59 expect(selections).toEqual(["gemini-saved"]);
60 }),
61 );
62
63 it.effect("reapplies an explicit selection even when setup reports the same model", () =>
64 Effect.gen(function* () {
65 const { runtime, selections } = makeModelRuntime([
66 { ...modelConfig, currentValue: "gemini-saved" },
67 ]);
68 const model = yield* applyAntigravityAcpModelSelection({
69 runtime,
70 model: "gemini-saved",
71 mapError: (cause) => cause,
72 });
73
74 expect(model).toBe("gemini-saved");
75 expect(selections).toEqual(["gemini-saved"]);
76 }),
77 );
78
79 it.effect.each([undefined, null, ANTIGRAVITY_DEFAULT_MODEL])(
80 "uses the native default for %s without sending an internal model ID",
81 (requestedModel) =>
82 Effect.gen(function* () {
83 const { runtime, selections } = makeModelRuntime();
84 const model = yield* applyAntigravityAcpModelSelection({
85 runtime,
86 model: requestedModel,
87 mapError: (cause) => cause,
88 });
89
90 expect(model).toBe("gemini-default");
91 expect(selections).toEqual([]);
92 }),
93 );
94
95 it.effect("selects the manifest default for the alias when the account offers it", () =>
96 Effect.gen(function* () {
97 const { runtime, selections } = makeModelRuntime();
98 const model = yield* applyAntigravityAcpModelSelection({
99 runtime,
100 model: ANTIGRAVITY_DEFAULT_MODEL,
101 defaultModel: "gemini-saved",
102 mapError: (cause) => cause,
103 });
104 expect(model).toBe("gemini-saved");
105 expect(selections).toEqual(["gemini-saved"]);
106
107 const { runtime: other, selections: otherSelections } = makeModelRuntime();
108 const fallback = yield* applyAntigravityAcpModelSelection({
109 runtime: other,
110 model: ANTIGRAVITY_DEFAULT_MODEL,
111 defaultModel: "gemini-not-offered",
112 mapError: (cause) => cause,
113 });
114 expect(fallback).toBe("gemini-default");
115 expect(otherSelections).toEqual([]);
116 }),
117 );
118
119 it.effect.each(["gemini-removed", "Gemini saved", "gemini-saved[reasoning=high]"])(
120 "rejects unavailable or non-native model ID %s without selecting a fallback",
121 (model) =>
122 Effect.gen(function* () {
123 const { runtime, selections } = makeModelRuntime();
124 const error = yield* applyAntigravityAcpModelSelection({
125 runtime,
126 model,
127 mapError: (cause) => cause,
128 }).pipe(Effect.flip);
129
130 expect(error).toMatchObject({
131 _tag: "AcpRequestError",
132 code: -32602,
133 errorMessage: expect.stringContaining(`'${model}' is unavailable`),
134 });
135 expect(selections).toEqual([]);
136 }),
137 );
138
139 it.effect("accepts exact model IDs from grouped native options", () =>
140 Effect.gen(function* () {
141 const { runtime, selections } = makeModelRuntime([
142 {
143 ...modelConfig,
144 options: [{ groupId: "gemini", name: "Gemini", options: modelConfig.options }],
145 },
146 ]);
147 const model = yield* applyAntigravityAcpModelSelection({
148 runtime,
149 model: "gemini-saved",
150 mapError: (cause) => cause,
151 });
152
153 expect(model).toBe("gemini-saved");
154 expect(selections).toEqual(["gemini-saved"]);
155 }),
156 );
157
158 it.effect("reports a native model-selection failure through the adapter error mapper", () =>
159 Effect.gen(function* () {
160 const nativeError = EffectAcpErrors.AcpRequestError.invalidParams("Model access changed.");
161 const { runtime } = makeModelRuntime([modelConfig], nativeError);
162 const error = yield* applyAntigravityAcpModelSelection({
163 runtime,
164 model: "gemini-saved",
165 mapError: (cause) => ({ operation: "select-model", cause }),
166 }).pipe(Effect.flip);
167
168 expect(error).toEqual({ operation: "select-model", cause: nativeError });
169 }),
170 );
171});
172
173describe("antigravityPermissionMode", () => {
174 it.each([
175 { runtimeMode: "approval-required", nativeMode: "default" },
176 { runtimeMode: "auto", nativeMode: "default" },
177 { runtimeMode: "auto-accept-edits", nativeMode: "auto_edit" },
178 { runtimeMode: "full-access", nativeMode: "yolo" },
179 ] satisfies ReadonlyArray<{ runtimeMode: RuntimeMode; nativeMode: string }>)(
180 "maps $runtimeMode to $nativeMode",
181 ({ runtimeMode, nativeMode }) => {
182 expect(antigravityPermissionMode(runtimeMode)).toBe(nativeMode);
183 },
184 );
185});
186
187const imageAttachment = {
188 type: "image",
189 id: "thread-00000000-0000-4000-8000-000000000001",
190 name: "screen.png",
191 mimeType: "image/png",
192 sizeBytes: 1,
193} satisfies ChatAttachment;
194
195const textAttachment = {
196 type: "file",
197 id: "thread-00000000-0000-4000-8000-000000000002-tsx",
198 name: "example.tsx",
199 mimeType: "application/octet-stream",
200 sizeBytes: 1,
201} satisfies ChatAttachment;
202
203const pdfAttachment = {
204 type: "file",
205 id: "thread-00000000-0000-4000-8000-000000000003-pdf",
206 name: "report.pdf",
207 mimeType: "application/pdf",
208 sizeBytes: 1,
209} satisfies ChatAttachment;
210
211const makeAttachmentFixture = Effect.fn("AntigravityAcpSupportTest.makeAttachmentFixture")(
212 function* () {
213 const fs = yield* FileSystem.FileSystem;
214 const path = yield* Path.Path;
215 const attachmentsDir = yield* fs.makeTempDirectoryScoped({
216 prefix: "t3-antigravity-attachments-",
217 });
218 const write = Effect.fn("AntigravityAcpSupportTest.writeAttachment")(function* (
219 attachment: ChatAttachment,
220 content: string | Uint8Array,
221 ) {
222 const filePath = resolveAttachmentPath({ attachmentsDir, attachment });
223 if (filePath === null) throw new Error("Invalid test attachment path.");
224 if (typeof content === "string") yield* fs.writeFileString(filePath, content);
225 else yield* fs.writeFile(filePath, content);
226 return { filePath, uri: (yield* path.toFileUrl(filePath)).href };
227 });
228 return { fs, attachmentsDir, write };
229 },
230);
231
232it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => {
233 it.effect("sends image bytes as native image content alongside the user prompt", () =>
234 Effect.gen(function* () {
235 const fixture = yield* makeAttachmentFixture();
236 const bytes = Buffer.from(
237 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jY9kAAAAASUVORK5CYII=",
238 "base64",
239 );
240 yield* fixture.write(imageAttachment, bytes);
241 const prompt = yield* buildAntigravityPrompt({
242 input: " Explain this image. ",
243 attachments: [imageAttachment],
244 attachmentsDir: fixture.attachmentsDir,
245 });
246
247 expect(prompt).toEqual([
248 { type: "text", text: "Explain this image." },
249 { type: "image", data: bytes.toString("base64"), mimeType: "image/png" },
250 ]);
251 }),
252 );
253
254 it.effect("embeds UTF-8 code from the selected environment and keeps the upload in place", () =>
255 Effect.gen(function* () {
256 const selectedEnvironment = yield* makeAttachmentFixture();
257 const otherEnvironment = yield* makeAttachmentFixture();
258 const source = ' const name = "caf\u00e9";\n';
259 const upload = yield* selectedEnvironment.write(textAttachment, source);
260 yield* otherEnvironment.write(textAttachment, "Different environment.");
261 const prompt = yield* buildAntigravityPrompt({
262 input: undefined,
263 attachments: [textAttachment],
264 attachmentsDir: selectedEnvironment.attachmentsDir,
265 });
266
267 expect(prompt).toEqual([
268 {
269 type: "resource",
270 resource: { uri: upload.uri, mimeType: "application/octet-stream", text: source },
271 },
272 ]);
273 expect(yield* selectedEnvironment.fs.readFileString(upload.filePath)).toBe(source);
274 expect(
275 yield* selectedEnvironment.fs.readDirectory(selectedEnvironment.attachmentsDir),
276 ).toHaveLength(1);
277 }),
278 );
279
280 it.effect("keeps folded clipboard text out of native context", () =>
281 Effect.gen(function* () {
282 const fixture = yield* makeAttachmentFixture();
283 const pastedText = {
284 ...textAttachment,
285 name: "pasted-text.txt",
286 mimeType: "text/plain",
287 source: { _tag: "pasted-text" as const },
288 } satisfies ChatAttachment;
289 yield* fixture.write(pastedText, "A very large crash report");
290
291 const prompt = yield* buildAntigravityPrompt({
292 input: "Inspect the pasted text only as needed.",
293 attachments: [pastedText],
294 attachmentsDir: fixture.attachmentsDir,
295 });
296
297 expect(prompt).toEqual([{ type: "text", text: "Inspect the pasted text only as needed." }]);
298 }),
299 );
300
301 it.effect("rejects missing folded clipboard text", () =>
302 Effect.gen(function* () {
303 const fixture = yield* makeAttachmentFixture();
304 const pastedText = {
305 ...textAttachment,
306 name: "pasted-text.txt",
307 mimeType: "text/plain",
308 source: { _tag: "pasted-text" as const },
309 } satisfies ChatAttachment;
310
311 const error = yield* buildAntigravityPrompt({
312 input: "Inspect the pasted text only as needed.",
313 attachments: [pastedText],
314 attachmentsDir: fixture.attachmentsDir,
315 }).pipe(Effect.flip);
316
317 expect(error).toMatchObject({
318 _tag: "AcpRequestError",
319 code: -32602,
320 errorMessage: "Could not read attachment 'pasted-text.txt'.",
321 });
322 }),
323 );
324
325 it.effect.each([
326 { name: "archive.zip", mimeType: "application/zip" },
327 { name: "clip.mp4", mimeType: "video/mp4" },
328 { name: "recording.aiff", mimeType: "audio/aiff" },
329 { name: "large.pdf", mimeType: "application/pdf", sizeBytes: 75_000_000 },
330 { name: "large.txt", mimeType: "text/plain", sizeBytes: 1024 * 1024 + 1 },
331 { name: "large.wav", mimeType: "audio/wav", sizeBytes: 20 * 1024 * 1024 + 1 },
332 ])("keeps $name as a file path without reading or spending the native media budget", (file) =>
333 Effect.gen(function* () {
334 const fixture = yield* makeAttachmentFixture();
335 const attachment = { ...textAttachment, sizeBytes: 50 * 1024 * 1024, ...file };
336 const upload = yield* fixture.write(attachment, "");
337 yield* fixture.fs.truncate(upload.filePath, attachment.sizeBytes);
338 const pdf = yield* fixture.write(pdfAttachment, "");
339 yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024);
340 const input = `Inspect the file at ${upload.filePath}`;
341 const prompt = yield* buildAntigravityPrompt({
342 input,
343 attachments: [attachment, pdfAttachment],
344 attachmentsDir: fixture.attachmentsDir,
345 }).pipe(
346 Effect.provideService(FileSystem.FileSystem, {
347 ...fixture.fs,
348 stream: () => {
349 throw new Error("Path attachments must not be read into the prompt");
350 },
351 }),
352 );
353 expect(prompt).toEqual([
354 { type: "text", text: input },
355 { type: "resource_link", uri: pdf.uri, name: "report.pdf", mimeType: "application/pdf" },
356 ]);
357 }),
358 );
359
360 it.effect("sends supported audio files as native audio content", () =>
361 Effect.gen(function* () {
362 const fixture = yield* makeAttachmentFixture();
363 const audioAttachment = {
364 ...textAttachment,
365 id: "recording-1",
366 name: "recording.wav",
367 mimeType: "audio/wav",
368 } satisfies ChatAttachment;
369 const bytes = Buffer.from("RIFF....WAVEfmt ", "latin1");
370 yield* fixture.write(audioAttachment, bytes);
371 const prompt = yield* buildAntigravityPrompt({
372 input: "Transcribe this.",
373 attachments: [audioAttachment],
374 attachmentsDir: fixture.attachmentsDir,
375 });
376
377 expect(prompt).toEqual([
378 { type: "text", text: "Transcribe this." },
379 { type: "audio", data: bytes.toString("base64"), mimeType: "audio/wav" },
380 ]);
381 }),
382 );
383
384 it.effect("uses a PDF file resource link without copying or reading its bytes", () =>
385 Effect.gen(function* () {
386 const fixture = yield* makeAttachmentFixture();
387 const upload = yield* fixture.write(pdfAttachment, "%PDF-1.7\n");
388 const forbidFileAccess = () =>
389 Effect.die("PDF prompt construction must only inspect the file.");
390 const prompt = yield* buildAntigravityPrompt({
391 input: undefined,
392 attachments: [pdfAttachment],
393 attachmentsDir: fixture.attachmentsDir,
394 }).pipe(
395 Effect.provideService(FileSystem.FileSystem, {
396 ...fixture.fs,
397 readFile: forbidFileAccess,
398 writeFile: forbidFileAccess,
399 writeFileString: forbidFileAccess,
400 copyFile: forbidFileAccess,
401 copy: forbidFileAccess,
402 rename: forbidFileAccess,
403 }),
404 );
405
406 expect(prompt).toEqual([
407 { type: "resource_link", uri: upload.uri, name: "report.pdf", mimeType: "application/pdf" },
408 ]);
409 }),
410 );
411
412 it.effect.each([
413 { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" },
414 ] satisfies ReadonlyArray<ChatAttachment>)(
415 "rejects $name instead of silently dropping it from a valid prompt",
416 (attachment) =>
417 Effect.gen(function* () {
418 const fixture = yield* makeAttachmentFixture();
419 yield* fixture.write(imageAttachment, new Uint8Array([1, 2, 3]));
420 const error = yield* buildAntigravityPrompt({
421 input: "Analyze every attachment.",
422 attachments: [imageAttachment, attachment],
423 attachmentsDir: fixture.attachmentsDir,
424 }).pipe(Effect.flip);
425
426 expect(error).toMatchObject({
427 _tag: "AcpRequestError",
428 code: -32602,
429 errorMessage: expect.stringContaining(`does not support '${attachment.name}'`),
430 });
431 }),
432 );
433
434 it.effect("rejects oversized images using file size instead of upload metadata", () =>
435 Effect.gen(function* () {
436 const fixture = yield* makeAttachmentFixture();
437 const upload = yield* fixture.write(imageAttachment, "");
438 yield* fixture.fs.truncate(upload.filePath, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1);
439 const error = yield* buildAntigravityPrompt({
440 input: "Read this attachment.",
441 attachments: [imageAttachment],
442 attachmentsDir: fixture.attachmentsDir,
443 }).pipe(Effect.flip);
444 expect(error).toMatchObject({
445 _tag: "AcpRequestError",
446 code: -32602,
447 errorMessage: expect.stringContaining("'screen.png' is too large"),
448 });
449 }),
450 );
451
452 it.effect("keeps PDF overflow on the file-path route", () =>
453 Effect.gen(function* () {
454 const fixture = yield* makeAttachmentFixture();
455 const secondAttachment = {
456 ...pdfAttachment,
457 id: `${pdfAttachment.id}-second`,
458 name: "second.pdf",
459 };
460 const first = yield* fixture.write(pdfAttachment, "");
461 const second = yield* fixture.write(secondAttachment, "");
462 yield* fixture.fs.truncate(first.filePath, (50 * 1024 * 1024) / 2);
463 yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2);
464 const input = {
465 input: `Read ${first.filePath} and ${second.filePath}`,
466 attachments: [pdfAttachment, secondAttachment],
467 attachmentsDir: fixture.attachmentsDir,
468 };
469 const prompt = yield* buildAntigravityPrompt(input);
470 expect(prompt).toEqual([
471 { type: "text", text: input.input },
472 {
473 type: "resource_link",
474 uri: first.uri,
475 name: "report.pdf",
476 mimeType: "application/pdf",
477 },
478 {
479 type: "resource_link",
480 uri: second.uri,
481 name: "second.pdf",
482 mimeType: "application/pdf",
483 },
484 ]);
485
486 yield* fixture.fs.truncate(second.filePath, (50 * 1024 * 1024) / 2 + 1);
487 expect(yield* buildAntigravityPrompt(input)).toEqual([
488 { type: "text", text: input.input },
489 {
490 type: "resource_link",
491 uri: first.uri,
492 name: "report.pdf",
493 mimeType: "application/pdf",
494 },
495 ]);
496 }),
497 );
498
499 it.effect("still rejects images when the native budget is full", () =>
500 Effect.gen(function* () {
501 const fixture = yield* makeAttachmentFixture();
502 const pdf = yield* fixture.write(pdfAttachment, "");
503 yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024);
504 yield* fixture.write(imageAttachment, new Uint8Array([1]));
505 const error = yield* buildAntigravityPrompt({
506 input: "Inspect both attachments.",
507 attachments: [pdfAttachment, imageAttachment],
508 attachmentsDir: fixture.attachmentsDir,
509 }).pipe(Effect.flip);
510 expect(error).toMatchObject({
511 _tag: "AcpRequestError",
512 errorMessage: expect.stringContaining("'screen.png' is too large"),
513 });
514 }),
515 );
516
517 it.effect("rejects aggregate overflow when a text upload grows after its size check", () =>
518 Effect.gen(function* () {
519 const fixture = yield* makeAttachmentFixture();
520 const pdf = yield* fixture.write(pdfAttachment, "");
521 const text = yield* fixture.write(textAttachment, "a");
522 yield* fixture.fs.truncate(pdf.filePath, 50 * 1024 * 1024 - 1);
523 const error = yield* buildAntigravityPrompt({
524 input: undefined,
525 attachments: [pdfAttachment, textAttachment],
526 attachmentsDir: fixture.attachmentsDir,
527 }).pipe(
528 Effect.provideService(FileSystem.FileSystem, {
529 ...fixture.fs,
530 stat: Effect.fn("AntigravityAcpSupportTest.growAfterStat")(function* (filePath: string) {
531 const info = yield* fixture.fs.stat(filePath);
532 if (filePath === text.filePath) {
533 yield* fixture.fs.writeFileString(filePath, "ab");
534 }
535 return info;
536 }),
537 }),
538 Effect.flip,
539 );
540
541 expect(error).toMatchObject({
542 _tag: "AcpRequestError",
543 code: -32602,
544 errorMessage: "Attachment 'example.tsx' changed while being read and is too large.",
545 });
546 }),
547 );
548
549 it.effect.each([
550 { bytes: new Uint8Array([0xff, 0xfe, 0x61]), message: "is not a UTF-8 text file" },
551 { bytes: new Uint8Array([0x61, 0, 0x62]), message: "contains binary data" },
552 ])("rejects binary data disguised as code: $message", ({ bytes, message }) =>
553 Effect.gen(function* () {
554 const fixture = yield* makeAttachmentFixture();
555 yield* fixture.write(textAttachment, bytes);
556 const error = yield* buildAntigravityPrompt({
557 input: undefined,
558 attachments: [textAttachment],
559 attachmentsDir: fixture.attachmentsDir,
560 }).pipe(Effect.flip);
561
562 expect(error).toMatchObject({
563 _tag: "AcpRequestError",
564 code: -32602,
565 errorMessage: expect.stringContaining(message),
566 });
567 }),
568 );
569
570 it.effect("reports a missing upload instead of sending only the remaining text", () =>
571 Effect.gen(function* () {
572 const fixture = yield* makeAttachmentFixture();
573 const error = yield* buildAntigravityPrompt({
574 input: "Read this image.",
575 attachments: [imageAttachment],
576 attachmentsDir: fixture.attachmentsDir,
577 }).pipe(Effect.flip);
578
579 expect(error).toMatchObject({
580 _tag: "AcpRequestError",
581 code: -32602,
582 errorMessage: "Could not read attachment 'screen.png'.",
583 });
584 }),
585 );
586
587 it.effect("rejects an empty turn", () =>
588 Effect.gen(function* () {
589 const fixture = yield* makeAttachmentFixture();
590 const error = yield* buildAntigravityPrompt({
591 input: " ",
592 attachments: [],
593 attachmentsDir: fixture.attachmentsDir,
594 }).pipe(Effect.flip);
595
596 expect(error).toMatchObject({
597 _tag: "AcpRequestError",
598 code: -32602,
599 errorMessage: "A turn requires text or supported attachments.",
600 });
601 }),
602 );
603});