/** * @file Tests POST /api/hooks/event for hooks marked `tool: "antigravity"` * (sent by agy through server/lib/antigravity-hooks.js): the conversation * becomes an Antigravity session with the workspace as its cwd, a run's first * PreInvocation is the prompt (the lane goes running) and later ones are * dropped, PostToolUse lands in the timeline, Stop sets the lane idle, a * claude lane at the same directory is untouched, and a hook from the * Antigravity IDE (transcript outside the CLI's directory) is ignored; a turn * after a Stop, a Stop with background work (ignored), a failed run (error), * and a symlinked Gemini dir. Plus the settings routes. The Gemini dir and the DB live in a temp dir. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); const os = require("node:os"); const fs = require("node:fs"); const http = require("node:http"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-hook-route-")); process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db"); process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; const { createApp, startServer } = require("../index"); const { db, stmts } = require("../db"); const lanes = require("../lib/lanes"); let server; let BASE; function request(method, urlPath, body, headers = {}) { return new Promise((resolve, reject) => { const url = new URL(urlPath, BASE); const req = http.request( { hostname: url.hostname, port: url.port, path: url.pathname, method, headers: { "Content-Type": "application/json", ...headers }, }, (res) => { let text = ""; res.on("data", (c) => (text += c)); res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text || "{}") })); } ); req.on("error", reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } before(async () => { server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; }); after(() => { server.close(); fs.rmSync(ROOT, { recursive: true, force: true }); }); const CLI = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity-cli"); /** A hook exactly as agy 1.2.13 sends it. */ function agyHook(hook_type, id, cwd, extra = {}, dir = CLI) { return request("POST", "/api/hooks/event", { hook_type, tool: "antigravity", data: { conversationId: id, workspacePaths: [cwd], transcriptPath: path.join(dir, "brain", id, ".system_generated/logs/transcript_full.jsonl"), artifactDirectoryPath: path.join(dir, "brain", id), modelName: "gemini-3.8-flash-high", ...extra, }, }); } const eventsOf = (sid) => db.prepare("SELECT event_type, tool_name FROM events WHERE session_id = ? ORDER BY id").all(sid); describe("antigravity hooks through /api/hooks/event", () => { it("runs a turn on the antigravity lane only, and records its tool calls", async () => { const cwd = fs.mkdtempSync(path.join(ROOT, "lane-")); const agyLane = lanes.createLane({ title: "a", cwd, tool: "antigravity" }); const claudeLane = lanes.createLane({ title: "c", cwd, tool: "claude" }); const id = "64389dfc-a91a-47d1-92f5-e153001026c7"; await agyHook("PreInvocation", id, cwd, { invocationNum: 0, initialNumSteps: 1 }); const s = stmts.getSession.get(id); assert.equal(s.tool, "antigravity"); assert.equal(s.cwd, cwd); assert.equal(s.name, `Antigravity ${id.slice(0, 8)}`); assert.equal(s.transcript_path, null); assert.equal(lanes.getLane(agyLane.id).status, "running"); assert.equal(lanes.getLane(agyLane.id).session_id, id); const later = await agyHook("PreInvocation", id, cwd, { invocationNum: 1 }); assert.equal(later.body.ignored, true); const call = { name: "view_file", args: { AbsolutePath: `${cwd}/note.txt` } }; await agyHook("PostToolUse", id, cwd, { stepIdx: 2, error: "", toolCall: call }); await agyHook("PostToolUse", id, cwd, { stepIdx: 3, error: "", toolCall: call }); await agyHook("PostInvocation", id, cwd, { invocationNum: 0 }); await agyHook("Stop", id, cwd, { executionNum: 0, terminationReason: "NO_TOOL_CALL", fullyIdle: true, error: "", }); assert.deepEqual( eventsOf(id).map((e) => [e.event_type, e.tool_name]), [ ["UserPromptSubmit", null], ["PostToolUse", "view_file"], ["PostToolUse", "view_file"], ["Stop", null], ] ); assert.equal(lanes.getLane(agyLane.id).status, "idle"); const c = lanes.getLane(claudeLane.id); assert.equal(c.session_id, null); assert.equal(c.status, claudeLane.status); }); it("ignores the Antigravity IDE's hooks and a malformed conversation id", async () => { const ide = path.join(process.env.AGENTHUB_GEMINI_DIR, "antigravity"); const id = "11111111-2222-3333-4444-555555555555"; const r = await agyHook("PreInvocation", id, "/w", { invocationNum: 0 }, ide); assert.equal(r.body.ignored, true); assert.equal(stmts.getSession.get(id), undefined); const bad = await agyHook("Stop", "../../x", "/w"); assert.equal(bad.body.ignored, true); const noData = await request("POST", "/api/hooks/event", { hook_type: "Stop", tool: "antigravity", data: "x", }); assert.equal(noData.body.ignored, true); }); }); describe("antigravity turns, errors and a symlinked Gemini dir", () => { it("starts a later turn after a Stop, whatever its invocationNum", async () => { const cwd = fs.mkdtempSync(path.join(ROOT, "lane-")); const lane = lanes.createLane({ title: "t", cwd, tool: "antigravity" }); const id = "22222222-2222-4222-8222-222222222222"; await agyHook("PreInvocation", id, cwd, { invocationNum: 0 }); await agyHook("Stop", id, cwd, { fullyIdle: true, error: "" }); assert.equal(lanes.getLane(lane.id).status, "idle"); await agyHook("PreInvocation", id, cwd, { invocationNum: 4 }); assert.equal(lanes.getLane(lane.id).status, "running"); assert.deepEqual( eventsOf(id).map((e) => e.event_type), ["UserPromptSubmit", "Stop", "UserPromptSubmit"] ); }); it("keeps a turn's own model calls out, but not the prompt after a turn with no Stop", async () => { const cwd = fs.mkdtempSync(path.join(ROOT, "lane-")); lanes.createLane({ title: "n", cwd, tool: "antigravity" }); const id = "55555555-5555-4555-8555-555555555555"; await agyHook("PreInvocation", id, cwd, { invocationNum: 0 }); const call = { name: "view_file", args: {} }; await agyHook("PostToolUse", id, cwd, { stepIdx: 1, error: "", toolCall: call }); const inTurn = await agyHook("PreInvocation", id, cwd, { invocationNum: 1 }); assert.equal(inTurn.body.ignored, true); // The run died on an API error (no Stop); the poll settled the session. db.prepare("UPDATE sessions SET status = 'completed' WHERE id = ?").run(id); const next = await agyHook("PreInvocation", id, cwd, { invocationNum: 2 }); assert.notEqual(next.body.ignored, true); assert.deepEqual( eventsOf(id).map((e) => e.event_type), ["UserPromptSubmit", "PostToolUse", "UserPromptSubmit"] ); }); it("ignores a Stop while background work runs, and lands a failed run as an error", async () => { const cwd = fs.mkdtempSync(path.join(ROOT, "lane-")); const lane = lanes.createLane({ title: "e", cwd, tool: "antigravity" }); const id = "33333333-3333-4333-8333-333333333333"; await agyHook("PreInvocation", id, cwd, { invocationNum: 0 }); const busy = await agyHook("Stop", id, cwd, { fullyIdle: false, error: "" }); assert.equal(busy.body.ignored, true); assert.equal(lanes.getLane(lane.id).status, "running"); await agyHook("Stop", id, cwd, { fullyIdle: true, terminationReason: "ERROR", error: "quota exceeded", }); assert.equal(stmts.getSession.get(id).status, "error"); assert.equal(lanes.getLane(lane.id).status, "idle"); }); it("accepts a CLI hook reported under the real path of a symlinked Gemini dir", async () => { const real = process.env.AGENTHUB_GEMINI_DIR; const link = path.join(ROOT, "gemini-link"); fs.mkdirSync(real, { recursive: true }); fs.symlinkSync(real, link); process.env.AGENTHUB_GEMINI_DIR = link; try { const id = "44444444-4444-4444-8444-444444444444"; const r = await agyHook("PreInvocation", id, "/w", { invocationNum: 0 }, CLI); assert.notEqual(r.body.ignored, true); assert.equal(stmts.getSession.get(id).tool, "antigravity"); } finally { process.env.AGENTHUB_GEMINI_DIR = real; } }); }); describe("GET/POST/DELETE /api/settings/antigravity-hooks", () => { it("installs, reports and removes AgentHub's named hook, and refuses another site", async () => { const file = path.join(process.env.AGENTHUB_GEMINI_DIR, "config", "hooks.json"); let r = await request("POST", "/api/settings/antigravity-hooks", null, { Origin: "https://evil.example", }); assert.equal(r.status, 403); assert.equal(fs.existsSync(file), false); r = await request("POST", "/api/settings/antigravity-hooks"); assert.equal(r.status, 200); assert.equal(r.body.installed, true); assert.ok(JSON.parse(fs.readFileSync(file, "utf8")).agenthub); r = await request("GET", "/api/settings/antigravity-hooks"); assert.equal(r.body.installed, true); r = await request("DELETE", "/api/settings/antigravity-hooks"); assert.equal(r.body.installed, false); assert.equal(fs.existsSync(file), false); }); });