Files
AgentHub/server/__tests__/antigravity-hook-route.test.js
T
nntrivi2001 969b06253c feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from
Settings → Dashboard to the Claude Code tab's AgentHub sub-tab
(ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub.
A home that is or contains the OS home is refused (EBADHOME).

Settings persistence: dashboard-saved settings go to
~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin
cache an update wipes. Load order: real env > the checkout's .env > that file.
The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put.

C1: codex-usage.js reads Codex rollout journals into sessions (new column
tool) and token_usage, tracked per file in codex_rollouts; forks deduped by
total vector, sub-agent threads folded into the root; pricing-sync also
prices OpenAI models; PricingSettings with a vendor filter.

C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on
a click, through a stable forwarder; Codex trust is never written. The hook
route creates tool='codex' sessions and folds sub-agent hooks into the root.

D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens
from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js
installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never
PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab;
Gemini pricing.

Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing
on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=,
clear-data covering the new tables, Codex child-thread events moving no
status, hooks owning status only while current, EFOREIGN for a forwarder
that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE
honoured from .env.

Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
2026-09-29 16:09:19 +07:00

232 lines
9.6 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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);
});
});