1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";
3import {
4 ANTIGRAVITY_DEFAULT_MODEL,
5 ProviderInstanceId,
6 ProviderSetupError,
7} from "@t3tools/contracts";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Fiber from "effect/Fiber";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import * as Queue from "effect/Queue";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Stream from "effect/Stream";
18import * as TestClock from "effect/testing/TestClock";
19import { type AcpError, AcpRequestError } from "effect-acp/errors";
20import type * as AcpSchema from "effect-acp/compat";
21import { expect } from "vite-plus/test";
22
23import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
24import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts";
25
26import {
27 type AntigravityTextGenerationOptions,
28 isAntigravityTextGenerationAvailable,
29 makeAntigravityTextGeneration,
30} from "./AntigravityTextGeneration.ts";
31
32type TextRuntime = Effect.Success<ReturnType<AntigravityTextGenerationOptions["makeRuntime"]>>;
33
34const requestContext = { requestId: "test-request", method: "test" };
35const SESSION_ID = "047c62f6-607b-44db-bfbe-f83b67e9e8b1";
36const modelSelection = {
37 instanceId: ProviderInstanceId.make("antigravity-test"),
38 model: "gemini-test",
39};
40const encodeMetadata = Schema.encodeEffect(
41 Schema.fromJsonString(Schema.Struct({ cwd: Schema.String })),
42);
43
44interface PromptContext {
45 readonly emit: (
46 update: AcpSchema.SessionNotification["update"],
47 sessionId?: string,
48 ) => Effect.Effect<void, AcpError>;
49 permission: Parameters<TextRuntime["handleRequestPermission"]>[0];
50 question: Parameters<TextRuntime["handleElicitation"]>[0];
51 readFile: Parameters<TextRuntime["handleReadTextFile"]>[0];
52 createTerminal: Parameters<TextRuntime["handleCreateTerminal"]>[0];
53 extension: Parameters<TextRuntime["handleUnknownExtRequest"]>[0];
54 readonly cwd: string;
55}
56
57const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* (
58 options: {
59 readonly outputs?: ReadonlyArray<string>;
60 readonly prompt?: (context: PromptContext) => Effect.Effect<AcpSchema.PromptResponse, AcpError>;
61 readonly startError?: AcpError;
62 readonly rejectAdmission?: boolean;
63 readonly sessionId?: string;
64 } = {},
65) {
66 const fs = yield* FileSystem.FileSystem;
67 const path = yield* Path.Path;
68 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-text-test-" });
69 const profileDirectory = path.join(root, "profile");
70 const projectDirectory = path.join(root, "project");
71 const conversations = path.join(profileDirectory, "antigravity-acp", "conversations");
72 const nativeSessionId = options.sessionId ?? SESSION_ID;
73 const sessionBase = path.join(conversations, SESSION_ID);
74 const brainDirectory = path.join(profileDirectory, "antigravity-acp", "brain", SESSION_ID);
75 yield* fs.makeDirectory(projectDirectory, { recursive: true });
76 yield* fs.makeDirectory(conversations, { recursive: true });
77 yield* fs.writeFileString(path.join(projectDirectory, "keep.txt"), "untouched");
78 yield* fs.writeFileString(path.join(conversations, "user-session.db"), "keep native history");
79 const enteredPrompt = yield* Deferred.make<void>();
80 const state = {
81 workspaces: [] as Array<string>,
82 closed: [] as Array<string>,
83 prompts: [] as Array<Parameters<TextRuntime["prompt"]>[0]>,
84 selectedModels: [] as Array<string>,
85 selectedModes: [] as Array<string>,
86 nativeFilesAtClose: [] as Array<boolean>,
87 cancellations: 0,
88 stop: undefined as Effect.Effect<void> | undefined,
89 };
90 const outputs = [...(options.outputs ?? ['{"title":"Repair login"}'])];
91 const incoming: Omit<PromptContext, "cwd" | "emit"> = {
92 permission: () => Effect.die("No permission handler."),
93 question: () => Effect.die("No question handler."),
94 readFile: () => Effect.die("No file handler."),
95 createTerminal: () => Effect.die("No terminal handler."),
96 extension: () => Effect.die("No extension handler."),
97 };
98
99 const withProcess: AntigravityTextGenerationOptions["withProcess"] = (stop, task) =>
100 Effect.gen(function* () {
101 if (options.rejectAdmission) {
102 return yield* new ProviderSetupError({
103 instanceId: modelSelection.instanceId,
104 operation: "launch",
105 detail: "Sign in before starting Antigravity.",
106 });
107 }
108 const scope = yield* Scope.Scope;
109 const child = yield* Effect.forkIn(task, scope);
110 state.stop = Fiber.interrupt(child).pipe(Effect.andThen(stop));
111 yield* Effect.addFinalizer(() =>
112 Effect.sync(() => {
113 state.stop = undefined;
114 }),
115 );
116 return yield* Fiber.await(child).pipe(
117 Effect.flatMap((exit) => exit),
118 Effect.ensuring(Fiber.interrupt(child)),
119 );
120 });
121
122 const makeRuntime: AntigravityTextGenerationOptions["makeRuntime"] = (cwd) =>
123 Effect.gen(function* () {
124 const events = yield* Queue.unbounded<AcpSessionRuntime.AcpSessionRuntimeEvent>();
125 state.workspaces.push(cwd);
126 expect(yield* fs.readDirectory(cwd).pipe(Effect.orDie)).toEqual([]);
127 yield* Effect.addFinalizer(() =>
128 Effect.gen(function* () {
129 state.nativeFilesAtClose.push(yield* fs.exists(`${sessionBase}.db`).pipe(Effect.orDie));
130 state.closed.push(cwd);
131 }),
132 );
133 let sessionUpdate: Parameters<TextRuntime["handleSessionUpdate"]>[0] = () => Effect.void;
134
135 return {
136 start: () =>
137 Effect.gen(function* () {
138 if (options.startError) return yield* options.startError;
139 yield* fs.makeDirectory(brainDirectory, { recursive: true }).pipe(Effect.orDie);
140 yield* fs.writeFileString(`${sessionBase}.db`, "helper session").pipe(Effect.orDie);
141 yield* fs.writeFileString(`${sessionBase}.db-wal`, "helper journal").pipe(Effect.orDie);
142 const metadata = yield* encodeMetadata({ cwd }).pipe(Effect.orDie);
143 yield* fs.writeFileString(`${sessionBase}.meta`, metadata).pipe(Effect.orDie);
144 yield* fs
145 .writeFileString(path.join(brainDirectory, "output.txt"), "helper artifact")
146 .pipe(Effect.orDie);
147 return {
148 sessionId: nativeSessionId,
149 initializeResult: { protocolVersion: 1 },
150 sessionSetupResult: { sessionId: nativeSessionId },
151 modelConfigId: "model",
152 };
153 }),
154 setMode: (mode) =>
155 Effect.sync(() => {
156 state.selectedModes.push(mode);
157 return {};
158 }),
159 getConfigOptions: Effect.succeed([
160 {
161 id: "model",
162 name: "Model",
163 category: "model",
164 type: "select",
165 currentValue: modelSelection.model,
166 options: [{ value: modelSelection.model, name: "Gemini test" }],
167 },
168 ]),
169 getEvents: () => Stream.fromQueue(events),
170 setModel: (model) =>
171 Effect.sync(() => {
172 state.selectedModels.push(model);
173 }),
174 prompt: (request) =>
175 Effect.gen(function* () {
176 state.prompts.push(request);
177 yield* Deferred.succeed(enteredPrompt, undefined);
178 const emit: PromptContext["emit"] = (update, sessionId = nativeSessionId) =>
179 sessionUpdate({ sessionId, update });
180 if (options.prompt) {
181 return yield* options.prompt({
182 emit,
183 ...incoming,
184 cwd,
185 });
186 }
187 const output = outputs.shift() ?? '{"title":"Repair login"}';
188 yield* emit({
189 sessionUpdate: "agent_thought_chunk",
190 content: { type: "text", text: "Choose concise text." },
191 });
192 yield* emit(
193 {
194 sessionUpdate: "agent_message_chunk",
195 content: { type: "text", text: "unrelated session" },
196 },
197 "another-session",
198 );
199 for (const text of [output.slice(0, 9), output.slice(9)]) {
200 yield* emit({
201 sessionUpdate: "agent_message_chunk",
202 content: { type: "text", text },
203 });
204 }
205 return { stopReason: "end_turn" };
206 }),
207 cancel: Effect.gen(function* () {
208 state.cancellations += 1;
209 const acknowledge = yield* Deferred.make<void>();
210 yield* Queue.offer(events, { _tag: "EventStreamBarrier", acknowledge });
211 yield* Deferred.await(acknowledge);
212 }),
213 handleSessionUpdate: (handler) =>
214 Effect.sync(() => {
215 sessionUpdate = handler;
216 }),
217 handleRequestPermission: (handler) =>
218 Effect.sync(() => {
219 incoming.permission = handler;
220 }),
221 handleElicitation: (handler) =>
222 Effect.sync(() => {
223 incoming.question = handler;
224 }),
225 handleReadTextFile: (handler) =>
226 Effect.sync(() => {
227 incoming.readFile = handler;
228 }),
229 handleWriteTextFile: () => Effect.void,
230 handleCreateTerminal: (handler) =>
231 Effect.sync(() => {
232 incoming.createTerminal = handler;
233 }),
234 handleTerminalOutput: () => Effect.void,
235 handleTerminalWaitForExit: () => Effect.void,
236 handleTerminalKill: () => Effect.void,
237 handleTerminalRelease: () => Effect.void,
238 handleUnknownExtRequest: (handler) =>
239 Effect.sync(() => {
240 incoming.extension = handler;
241 }),
242 } satisfies TextRuntime;
243 });
244
245 const textGeneration = yield* makeAntigravityTextGeneration({
246 profileDirectory,
247 makeRuntime,
248 withProcess,
249 });
250 const titleInput = { cwd: projectDirectory, message: "Repair Google login", modelSelection };
251 const assertCleaned = Effect.gen(function* () {
252 expect(state.closed).toEqual(state.workspaces);
253 for (const workspace of state.workspaces) {
254 expect(yield* fs.exists(workspace)).toBe(false);
255 }
256 expect(yield* fs.exists(`${sessionBase}.db`)).toBe(false);
257 expect(yield* fs.exists(`${sessionBase}.db-wal`)).toBe(false);
258 expect(yield* fs.exists(`${sessionBase}.meta`)).toBe(false);
259 expect(yield* fs.exists(brainDirectory)).toBe(false);
260 expect(yield* fs.readFileString(path.join(projectDirectory, "keep.txt"))).toBe("untouched");
261 expect(yield* fs.readFileString(path.join(conversations, "user-session.db"))).toBe(
262 "keep native history",
263 );
264 });
265 return {
266 fs,
267 path,
268 profileDirectory,
269 projectDirectory,
270 sessionBase,
271 brainDirectory,
272 state,
273 incoming,
274 enteredPrompt,
275 textGeneration,
276 titleInput,
277 assertCleaned,
278 };
279});
280
281it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => {
282 it.effect(
283 "generates all helper types in empty workspaces and removes only owned session files",
284 () =>
285 Effect.gen(function* () {
286 const fixture = yield* makeFixture({
287 outputs: [
288 '{"subject":" Repair Google login.\\nExtra line","body":" Keep the remote callback. ","branch":"Repair Login"}',
289 '```json\n{"title":" Repair Google login\\nExtra line","body":" ## Summary\\nSupport remote callbacks. "}\n```',
290 '{"branch":" Repair Google Login "}',
291 '{"title":" \\"Repair Google login\\" "}',
292 ],
293 });
294 const common = { cwd: fixture.projectDirectory, modelSelection };
295 expect(
296 yield* fixture.textGeneration.generateCommitMessage({
297 ...common,
298 branch: "feature/login",
299 stagedSummary: "M login.ts",
300 stagedPatch: "+handleRemoteCallback()",
301 includeBranch: true,
302 }),
303 ).toEqual({
304 subject: "Repair Google login",
305 body: "Keep the remote callback.",
306 branch: "feature/repair-login",
307 });
308 expect(
309 yield* fixture.textGeneration.generatePrContent({
310 ...common,
311 baseBranch: "main",
312 headBranch: "feature/login",
313 commitSummary: "Repair login",
314 diffSummary: "M login.ts",
315 diffPatch: "+handleRemoteCallback()",
316 }),
317 ).toEqual({ title: "Repair Google login", body: "## Summary\nSupport remote callbacks." });
318 expect(
319 yield* fixture.textGeneration.generateBranchName({
320 ...common,
321 message: "Repair Google login",
322 }),
323 ).toEqual({ branch: "repair-google-login" });
324 expect(yield* fixture.textGeneration.generateThreadTitle(fixture.titleInput)).toEqual({
325 title: "Repair Google login",
326 });
327 expect(new Set(fixture.state.workspaces).size).toBe(4);
328 expect(fixture.state.workspaces).not.toContain(fixture.projectDirectory);
329 expect(fixture.state.nativeFilesAtClose).toEqual([true, true, true, true]);
330 expect(fixture.state.selectedModes).toEqual(["default", "default", "default", "default"]);
331 expect(fixture.state.selectedModels).toEqual(Array(4).fill(modelSelection.model));
332 expect(fixture.state.prompts[0]?.prompt).toEqual([
333 { type: "text", text: expect.stringContaining("+handleRemoteCallback()") },
334 ]);
335 yield* fixture.assertCleaned;
336 }).pipe(Effect.scoped),
337 );
338
339 it.effect.each(["tool_call", "tool_call_update"] as const)(
340 "aborts on %s even without a permission request",
341 (sessionUpdate) =>
342 Effect.gen(function* () {
343 const fixture = yield* makeFixture({
344 prompt: ({ emit }) =>
345 emit({ sessionUpdate, toolCallId: "tool-1", title: "Read files" }).pipe(
346 Effect.andThen(Effect.never),
347 ),
348 });
349 const error = yield* fixture.textGeneration
350 .generateThreadTitle(fixture.titleInput)
351 .pipe(Effect.flip);
352 expect(error.detail).toContain("tool work");
353 expect(fixture.state.cancellations).toBe(1);
354 yield* fixture.assertCleaned;
355 }).pipe(Effect.scoped),
356 );
357
358 it.effect("rejects native permission and choice requests instead of answering them", () =>
359 Effect.gen(function* () {
360 const fixture = yield* makeFixture({ prompt: () => Effect.never });
361 const child = yield* fixture.textGeneration
362 .generateThreadTitle(fixture.titleInput)
363 .pipe(Effect.forkChild);
364 yield* Deferred.await(fixture.enteredPrompt);
365 const reply = yield* fixture.incoming.permission(
366 {
367 sessionId: SESSION_ID,
368 toolCall: { toolCallId: "question-1", title: "Choose a title", kind: "other" },
369 options: [
370 { optionId: "first-native-choice", name: "Use this title", kind: "allow_once" },
371 { optionId: "second-native-choice", name: "Use this title", kind: "allow_once" },
372 ],
373 },
374 requestContext,
375 );
376 const error = yield* Fiber.join(child).pipe(Effect.flip);
377 expect(reply).toEqual({ outcome: { outcome: "cancelled" } });
378 expect(error.detail).toContain("permission or user input");
379 yield* fixture.assertCleaned;
380 }).pipe(Effect.scoped),
381 );
382
383 it.effect("declines elicitation and stops the helper", () =>
384 Effect.gen(function* () {
385 const fixture = yield* makeFixture({ prompt: () => Effect.never });
386 const child = yield* fixture.textGeneration
387 .generateThreadTitle(fixture.titleInput)
388 .pipe(Effect.forkChild);
389 yield* Deferred.await(fixture.enteredPrompt);
390 const reply = yield* fixture.incoming.question(
391 {
392 sessionId: SESSION_ID,
393 mode: "form",
394 message: "Name this branch",
395 requestedSchema: { type: "object", properties: {} },
396 },
397 requestContext,
398 );
399 const error = yield* Fiber.join(child).pipe(Effect.flip);
400 expect(reply).toEqual({ action: "decline" });
401 expect(error.detail).toContain("user input");
402 yield* fixture.assertCleaned;
403 }).pipe(Effect.scoped),
404 );
405
406 it.effect.each(["file", "terminal", "extension"] as const)(
407 "denies unexpected %s requests",
408 (kind) =>
409 Effect.gen(function* () {
410 const fixture = yield* makeFixture({ prompt: () => Effect.never });
411 const child = yield* fixture.textGeneration
412 .generateThreadTitle(fixture.titleInput)
413 .pipe(Effect.forkChild);
414 yield* Deferred.await(fixture.enteredPrompt);
415 const request =
416 kind === "file"
417 ? fixture.incoming.readFile(
418 { sessionId: SESSION_ID, path: "/not-allowed" },
419 requestContext,
420 )
421 : kind === "terminal"
422 ? fixture.incoming.createTerminal(
423 { sessionId: SESSION_ID, command: "not-allowed" },
424 requestContext,
425 )
426 : fixture.incoming.extension("_ask_user", {}, requestContext);
427 const denied = yield* request.pipe(Effect.exit);
428 expect(Exit.isFailure(denied)).toBe(true);
429 const error = yield* Fiber.join(child).pipe(Effect.flip);
430 expect(error.detail).toContain("tool or user input");
431 yield* fixture.assertCleaned;
432 }).pipe(Effect.scoped),
433 );
434
435 it.effect.each([
436 { output: " ", detail: "empty" },
437 { output: "No JSON here", detail: "invalid structured output" },
438 { output: '{"title":42}', detail: "invalid structured output" },
439 { output: "x".repeat(128_001), detail: "output limit" },
440 ])("rejects $detail output and closes the runtime", ({ output, detail }) =>
441 Effect.gen(function* () {
442 const fixture = yield* makeFixture({ outputs: [output] });
443 const error = yield* fixture.textGeneration
444 .generateThreadTitle(fixture.titleInput)
445 .pipe(Effect.flip);
446 expect(error.detail).toContain(detail);
447 yield* fixture.assertCleaned;
448 }).pipe(Effect.scoped),
449 );
450
451 it.effect("does not return partial JSON after native cancellation", () =>
452 Effect.gen(function* () {
453 const fixture = yield* makeFixture({
454 prompt: ({ emit }) =>
455 emit({
456 sessionUpdate: "agent_message_chunk",
457 content: { type: "text", text: '{"title":"Partial title"}' },
458 }).pipe(Effect.as({ stopReason: "cancelled" })),
459 });
460 const error = yield* fixture.textGeneration
461 .generateThreadTitle(fixture.titleInput)
462 .pipe(Effect.flip);
463 expect(error.detail).toContain("cancelled");
464 yield* fixture.assertCleaned;
465 }).pipe(Effect.scoped),
466 );
467
468 it.effect("rejects a helper that writes files in its temporary workspace", () =>
469 Effect.gen(function* () {
470 const fs = yield* FileSystem.FileSystem;
471 const path = yield* Path.Path;
472 const fixture = yield* makeFixture({
473 prompt: ({ cwd, emit }) =>
474 Effect.gen(function* () {
475 yield* fs
476 .writeFileString(path.join(cwd, "unexpected.txt"), "unexpected")
477 .pipe(Effect.orDie);
478 yield* emit({
479 sessionUpdate: "agent_message_chunk",
480 content: { type: "text", text: '{"title":"Ignored output"}' },
481 });
482 return { stopReason: "end_turn" };
483 }),
484 });
485 const error = yield* fixture.textGeneration
486 .generateThreadTitle(fixture.titleInput)
487 .pipe(Effect.flip);
488 expect(error.detail).toContain("wrote files");
489 yield* fixture.assertCleaned;
490 }).pipe(Effect.scoped),
491 );
492
493 it.effect("fails an unavailable model before prompting", () =>
494 Effect.gen(function* () {
495 const fixture = yield* makeFixture();
496 const error = yield* fixture.textGeneration
497 .generateThreadTitle({
498 ...fixture.titleInput,
499 modelSelection: { ...modelSelection, model: "not-in-the-account" },
500 })
501 .pipe(Effect.flip);
502 expect(error.detail).toContain("select the Antigravity model");
503 expect(fixture.state.prompts).toEqual([]);
504 yield* fixture.assertCleaned;
505 }).pipe(Effect.scoped),
506 );
507
508 it.effect("uses the native default without sending T3's default selection as a model ID", () =>
509 Effect.gen(function* () {
510 const fixture = yield* makeFixture();
511 const result = yield* fixture.textGeneration.generateThreadTitle({
512 ...fixture.titleInput,
513 modelSelection: { ...modelSelection, model: ANTIGRAVITY_DEFAULT_MODEL },
514 });
515 expect(result).toEqual({ title: "Repair login" });
516 expect(fixture.state.selectedModels).toEqual([]);
517 yield* fixture.assertCleaned;
518 }).pipe(Effect.scoped),
519 );
520
521 it.effect.each([
522 {
523 name: "hooks.json",
524 value: '{"default":{"PreInvocation":[{"command":"do-not-run"}]}}',
525 available: false,
526 },
527 {
528 name: "mcp_config.json",
529 value: '{"mcpServers":{"server":{"command":"do-not-run"}}}',
530 available: false,
531 },
532 { name: "hooks.json", value: "invalid JSON", available: false },
533 { name: "mcp_config.json", value: "x".repeat(64_001), available: false },
534 { name: "hooks.json", value: "{}", available: true },
535 { name: "hooks.json", value: '{"hooks":{}}', available: true },
536 { name: "mcp_config.json", value: '{"mcpServers":{}}', available: true },
537 ])("checks $name before starting a helper, available=$available", ({ name, value, available }) =>
538 Effect.gen(function* () {
539 const fixture = yield* makeFixture();
540 const configDirectory = fixture.path.join(fixture.profileDirectory, "config");
541 yield* fixture.fs.makeDirectory(configDirectory, { recursive: true });
542 yield* fixture.fs.writeFileString(fixture.path.join(configDirectory, name), value);
543 expect(yield* isAntigravityTextGenerationAvailable(fixture.profileDirectory)).toBe(available);
544 const result = yield* fixture.textGeneration
545 .generateThreadTitle(fixture.titleInput)
546 .pipe(Effect.exit);
547 expect(Exit.isSuccess(result)).toBe(available);
548 expect(fixture.state.workspaces.length).toBe(available ? 1 : 0);
549 yield* fixture.assertCleaned;
550 }).pipe(Effect.scoped),
551 );
552
553 it.effect("does not start another runtime or prompt after authentication fails", () =>
554 Effect.gen(function* () {
555 const fixture = yield* makeFixture({
556 startError: new AcpRequestError({ code: -32000, errorMessage: "Authentication required" }),
557 });
558 const error = yield* fixture.textGeneration
559 .generateThreadTitle(fixture.titleInput)
560 .pipe(Effect.flip);
561 expect(error._tag).toBe("TextGenerationError");
562 expect(fixture.state.workspaces).toHaveLength(1);
563 expect(fixture.state.prompts).toEqual([]);
564 yield* fixture.assertCleaned;
565 }).pipe(Effect.scoped),
566 );
567
568 it.effect("does not launch during sign-out or before sign-in", () =>
569 Effect.gen(function* () {
570 const fixture = yield* makeFixture({ rejectAdmission: true });
571 const error = yield* fixture.textGeneration
572 .generateThreadTitle(fixture.titleInput)
573 .pipe(Effect.flip);
574 expect(error._tag).toBe("TextGenerationError");
575 expect(fixture.state.workspaces).toEqual([]);
576 yield* fixture.assertCleaned;
577 }).pipe(Effect.scoped),
578 );
579
580 it.effect("times out a stalled helper and waits for cleanup", () =>
581 Effect.gen(function* () {
582 const fixture = yield* makeFixture({ prompt: () => Effect.never });
583 const child = yield* fixture.textGeneration
584 .generateThreadTitle(fixture.titleInput)
585 .pipe(Effect.forkChild);
586 yield* Deferred.await(fixture.enteredPrompt);
587 yield* TestClock.adjust(180_000);
588 const error = yield* Fiber.join(child).pipe(Effect.flip);
589 expect(error.detail).toContain("timed out");
590 expect(fixture.state.cancellations).toBe(1);
591 yield* fixture.assertCleaned;
592 }).pipe(Effect.scoped),
593 );
594
595 it.effect.each(["caller", "sign-out"] as const)(
596 "closes all helper resources when stopped by %s",
597 (source) =>
598 Effect.gen(function* () {
599 const fixture = yield* makeFixture({ prompt: () => Effect.never });
600 const child = yield* fixture.textGeneration
601 .generateThreadTitle(fixture.titleInput)
602 .pipe(Effect.forkChild);
603 yield* Deferred.await(fixture.enteredPrompt);
604 if (source === "caller") {
605 yield* Fiber.interrupt(child);
606 } else {
607 if (!fixture.state.stop) return yield* Effect.die("No tracked helper to stop.");
608 yield* fixture.state.stop;
609 yield* Fiber.await(child);
610 }
611 expect(fixture.state.cancellations).toBe(1);
612 yield* fixture.assertCleaned;
613 }).pipe(Effect.scoped),
614 );
615
616 it.effect("refuses unsafe native session IDs without deleting other files", () =>
617 Effect.gen(function* () {
618 const fixture = yield* makeFixture({ sessionId: "../../user-session" });
619 const error = yield* fixture.textGeneration
620 .generateThreadTitle(fixture.titleInput)
621 .pipe(Effect.flip);
622 expect(error.detail).toContain("invalid text helper session ID");
623 expect(fixture.state.prompts).toEqual([]);
624 expect(yield* fixture.fs.readFileString(`${fixture.sessionBase}.db`)).toBe("helper session");
625 expect(fixture.state.closed).toEqual(fixture.state.workspaces);
626 }).pipe(Effect.scoped),
627 );
628
629 it.effect("keeps native sessions whose metadata belongs to a different workspace", () =>
630 Effect.gen(function* () {
631 const fixture = yield* makeFixture();
632 yield* fixture.fs.writeFileString(`${fixture.sessionBase}.db`, "keep this session");
633 const metadata = yield* encodeMetadata({ cwd: fixture.projectDirectory });
634 yield* fixture.fs.writeFileString(`${fixture.sessionBase}.meta`, metadata);
635 yield* removeAntigravitySessionFiles({
636 profileDirectory: fixture.profileDirectory,
637 sessionId: SESSION_ID,
638 cwd: "different-temporary-workspace",
639 });
640 expect(yield* fixture.fs.readFileString(`${fixture.sessionBase}.db`)).toBe(
641 "keep this session",
642 );
643 expect(yield* fixture.fs.readFileString(`${fixture.sessionBase}.meta`)).toBe(metadata);
644 }).pipe(Effect.scoped),
645 );
646});