From c11322f3306221b1d77a0129e7d5b9f85e165123 Mon Sep 17 00:00:00 2001 From: Marko Milanovic Date: Thu, 29 Oct 2026 23:18:59 +0100 Subject: [PATCH 1/2] fix(antigravity): accept empty skill directories --- .../AntigravityTextGeneration.test.ts | 90 ++++++++++++++----- .../AntigravityTextGeneration.ts | 36 +++++++- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 3fcafcc15178..62f15ef8fdb2 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -5,6 +5,7 @@ import { ProviderInstanceId, ProviderSetupError, } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -60,6 +61,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* readonly startError?: AcpError; readonly rejectAdmission?: boolean; readonly sessionId?: string; + readonly startupSkills?: boolean; } = {}, ) { const fs = yield* FileSystem.FileSystem; @@ -135,6 +137,13 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* start: () => Effect.gen(function* () { if (options.startError) return yield* options.startError; + if (options.startupSkills) { + for (const directory of [".agents", ".gemini"]) { + yield* fs + .makeDirectory(path.join(cwd, directory, "skills"), { recursive: true }) + .pipe(Effect.orDie); + } + } yield* fs.makeDirectory(brainDirectory, { recursive: true }).pipe(Effect.orDie); yield* fs.writeFileString(`${sessionBase}.db`, "helper session").pipe(Effect.orDie); yield* fs.writeFileString(`${sessionBase}.db-wal`, "helper journal").pipe(Effect.orDie); @@ -284,6 +293,7 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { () => Effect.gen(function* () { const fixture = yield* makeFixture({ + startupSkills: true, outputs: [ '{"subject":" Repair Google login.\\nExtra line","body":" Keep the remote callback. ","branch":"Repair Login"}', '```json\n{"title":" Repair Google login\\nExtra line","body":" ## Summary\\nSupport remote callbacks. "}\n```', @@ -442,29 +452,51 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); - it.effect("rejects a helper that writes files in its temporary workspace", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const fixture = yield* makeFixture({ - prompt: ({ cwd, emit }) => - Effect.gen(function* () { - yield* fs - .writeFileString(path.join(cwd, "unexpected.txt"), "unexpected") - .pipe(Effect.orDie); - yield* emit({ - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: '{"title":"Ignored output"}' }, - }); - return { stopReason: "end_turn" }; - }), - }); - const error = yield* fixture.textGeneration - .generateThreadTitle(fixture.titleInput) - .pipe(Effect.flip); - expect(error.detail).toContain("wrote files"); - yield* fixture.assertCleaned; - }).pipe(Effect.scoped), + it.effect.each([ + { relativePath: "unexpected.txt", kind: "file" }, + { relativePath: ".agents/skills/unexpected.txt", kind: "file" }, + { relativePath: ".gemini/unexpected", kind: "directory" }, + { relativePath: ".agents/skills", kind: "file" }, + ...(symlinksSupported ? [{ relativePath: ".gemini/skills", kind: "symlink" }] : []), + ])( + "rejects an unexpected $kind at $relativePath in its temporary workspace", + ({ relativePath, kind }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = + kind === "symlink" + ? yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-link-test-" }) + : undefined; + const fixture = yield* makeFixture({ + startupSkills: true, + prompt: ({ cwd, emit }) => + Effect.gen(function* () { + const entry = path.join(cwd, relativePath); + if (yield* fs.exists(entry).pipe(Effect.orDie)) { + yield* fs.remove(entry, { recursive: true }).pipe(Effect.orDie); + } + yield* ( + target !== undefined + ? fs.symlink(target, entry) + : kind === "directory" + ? fs.makeDirectory(entry) + : fs.writeFileString(entry, "unexpected") + ).pipe(Effect.orDie); + yield* emit({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: '{"title":"Ignored output"}' }, + }); + return { stopReason: "end_turn" }; + }), + }); + const error = yield* fixture.textGeneration + .generateThreadTitle(fixture.titleInput) + .pipe(Effect.flip); + expect(error.detail).toContain("wrote files"); + if (target !== undefined) expect(yield* fs.exists(target)).toBe(true); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), ); it.effect("uses the native default without sending T3's default selection as a model ID", () => @@ -576,3 +608,15 @@ it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { }).pipe(Effect.scoped), ); }); + +it.effect("retains filesystem services supplied only during construction", () => + Effect.gen(function* () { + const fixture = yield* makeFixture({ startupSkills: true }).pipe( + Effect.provide(NodeServices.layer), + ); + expect(yield* fixture.textGeneration.generateThreadTitle(fixture.titleInput)).toEqual({ + title: "Repair login", + }); + yield* fixture.assertCleaned; + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 921a2f118e34..7e954932fcbd 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -24,6 +24,36 @@ const Configuration = Schema.Record(Schema.String, Schema.Unknown); const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configuration)); const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); +const hasOnlyRuntimeDirectories = Effect.fn("AntigravityTextGeneration.hasOnlyRuntimeDirectories")( + function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.realPath(cwd); + // ACP creates these empty skill directories before the first prompt. + const allowed = new Set([ + path.join(root, ".agents"), + path.join(root, ".agents", "skills"), + path.join(root, ".gemini"), + path.join(root, ".gemini", "skills"), + ]); + const directories = [root]; + for (const directory of directories) { + for (const name of yield* fs.readDirectory(directory)) { + const entry = path.join(directory, name); + if ( + !allowed.has(entry) || + (yield* fs.realPath(entry)) !== entry || + (yield* fs.stat(entry)).type !== "Directory" + ) { + return false; + } + directories.push(entry); + } + } + return true; + }, +); + type AntigravityTextRuntime = Pick< AcpSessionRuntime.AcpSessionRuntime["Service"], | "start" @@ -253,7 +283,11 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ); }).pipe(Effect.scoped); - if ((yield* fs.readDirectory(cwd)).length > 0) { + const validWorkspace = yield* hasOnlyRuntimeDirectories(cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + if (!validWorkspace) { return yield* new TextGenerationError({ operation, detail: "Antigravity wrote files during text generation.", From 498c7bb504d703b8a33843547fd0ef5bfe98e385 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 08:01:18 +0000 Subject: [PATCH 2/2] fix(antigravity): capture workspace validator services --- .../AntigravityTextGeneration.test.ts | 8 +-- .../AntigravityTextGeneration.ts | 64 +++++++++---------- 2 files changed, 33 insertions(+), 39 deletions(-) diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 62f15ef8fdb2..7e1f1446d1a5 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -288,12 +288,12 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* }); it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => { - it.effect( - "generates all helper types in empty workspaces and removes only owned session files", - () => + it.effect.each([false, true])( + "generates all helper types and cleans up with startupSkills=%s", + (startupSkills) => Effect.gen(function* () { const fixture = yield* makeFixture({ - startupSkills: true, + startupSkills, outputs: [ '{"subject":" Repair Google login.\\nExtra line","body":" Keep the remote callback. ","branch":"Repair Login"}', '```json\n{"title":" Repair Google login\\nExtra line","body":" ## Summary\\nSupport remote callbacks. "}\n```', diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 7e954932fcbd..22fce172b183 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -24,36 +24,6 @@ const Configuration = Schema.Record(Schema.String, Schema.Unknown); const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configuration)); const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); -const hasOnlyRuntimeDirectories = Effect.fn("AntigravityTextGeneration.hasOnlyRuntimeDirectories")( - function* (cwd: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.realPath(cwd); - // ACP creates these empty skill directories before the first prompt. - const allowed = new Set([ - path.join(root, ".agents"), - path.join(root, ".agents", "skills"), - path.join(root, ".gemini"), - path.join(root, ".gemini", "skills"), - ]); - const directories = [root]; - for (const directory of directories) { - for (const name of yield* fs.readDirectory(directory)) { - const entry = path.join(directory, name); - if ( - !allowed.has(entry) || - (yield* fs.realPath(entry)) !== entry || - (yield* fs.stat(entry)).type !== "Directory" - ) { - return false; - } - directories.push(entry); - } - } - return true; - }, -); - type AntigravityTextRuntime = Pick< AcpSessionRuntime.AcpSessionRuntime["Service"], | "start" @@ -124,6 +94,34 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const hasOnlyRuntimeDirectories = Effect.fn( + "AntigravityTextGeneration.hasOnlyRuntimeDirectories", + )(function* (cwd: string) { + const root = yield* fs.realPath(cwd); + // ACP creates these empty skill directories before the first prompt. + const allowed = new Set([ + path.join(root, ".agents"), + path.join(root, ".agents", "skills"), + path.join(root, ".gemini"), + path.join(root, ".gemini", "skills"), + ]); + const directories = [root]; + for (const directory of directories) { + for (const name of yield* fs.readDirectory(directory)) { + const entry = path.join(directory, name); + if ( + !allowed.has(entry) || + (yield* fs.realPath(entry)) !== entry || + (yield* fs.stat(entry)).type !== "Directory" + ) { + return false; + } + directories.push(entry); + } + } + return true; + }); + const available = isAntigravityTextGenerationAvailable(options.profileDirectory).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -283,11 +281,7 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener ); }).pipe(Effect.scoped); - const validWorkspace = yield* hasOnlyRuntimeDirectories(cwd).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ); - if (!validWorkspace) { + if (!(yield* hasOnlyRuntimeDirectories(cwd))) { return yield* new TextGenerationError({ operation, detail: "Antigravity wrote files during text generation.",