mirror of
https://github.com/rohitg00/agentmemory.git
synced 2026-09-14 20:16:33 +08:00
d60652a705
* feat(cli): native hooks adapter for Antigravity CLI (agy) Antigravity ships two products with unrelated configuration: the IDE, already wired by `connect antigravity`, and the `agy` CLI, which reads its customizations out of ~/.gemini/ and until now was not wired at all. This adds `connect antigravity-cli` for the latter — MCP via ~/.gemini/config/mcp_config.json, plus optional native auto-capture hooks behind --with-hooks. Unlike Droid (#1130), the Codex merge engine could not be reused. The Antigravity hooks contract differs in three ways: * hooks.json is a map of *named* hook bundles at the root, not the `{ hooks: { <Event>: [...] } }` envelope, so antigravity-hooks.ts implements a merge that owns top-level keys instead of per-event entries. User-authored bundles are preserved; a re-install replaces only the bundle whose commands point under the bundled plugin dir. * only five events exist (PreToolUse, PostToolUse, PreInvocation, PostInvocation, Stop) — no SessionStart/SessionEnd/UserPromptSubmit, so the session lifecycle is synthesized from the first PreInvocation and from Stop. PostInvocation is left unwired to avoid double-capture. * the stdin payload is camelCase and nested (`toolCall.args` with PascalCase keys, `conversationId`, `workspacePaths`), and stdout must be a JSON object — `pre-tool-use.mjs` writes raw prose when context injection is on. plugin/scripts/antigravity-bridge.mjs bridges all three: it normalizes the payload onto the shape the bundled hooks already accept, maps Cascade tool names (view_file, replace_file_content, …) onto the read/edit/write/grep vocabulary the capture heuristics use, pipes to the right script, discards child stdout and always answers `{}` so Antigravity's own permission decisions are never overridden. Event names, tool names and arg keys were verified against the shipped agy binary rather than docs alone (docs disagree on the global hooks path); the customization dir is ~/.gemini/config/, matching where agy already keeps mcp_config.json and plugins/. Signed-off-by: Bertho Joris <bertho_joris@yahoo.co.id> * fix(cli): keep $-bearing plugin paths literal when resolving hook commands resolveBundle() expanded ${CLAUDE_PLUGIN_ROOT} via String.prototype.replace with a string argument, so a plugin root containing `$$`, `$&`, "$`" or `$'` was read as a replacement pattern and rewritten: C:/plug$&in -> C:/plug${CLAUDE_PLUGIN_ROOT}in/scripts/... C:/plug$$in -> C:/plug$in/scripts/... `$1` and `$<name>` are unaffected — the regex has no capture groups. Switching to a replacer function keeps the path verbatim. The failure mode this closes is silent: the hook installs with a broken command and auto-capture simply never fires. Regression test builds the manifest against a temp plugin root named `plug$&$$in` and asserts the resolved command contains it literally. Reported by CodeRabbit on #1146. Signed-off-by: Bertho Joris <bertho_joris@yahoo.co.id> * fix(antigravity): emit an explicit allow decision from the PreToolUse hook Antigravity documents `decision` as a required field of PreToolUse hook output, and agy treats a response that omits it as a denial: the bare `{}` the bridge used to write made the agent refuse every matched tool call (reported against agy 1.0.5 in cmux#5358) instead of passively capturing it. `responseFor` now answers PreToolUse with `{"decision":"allow"}` and leaves every other event on `{}`, so no event that carries no permission decision starts overriding the user's own settings. The response is written from the `finally` block, so a failed capture or an unparseable payload still produces the contract rather than empty stdout, which PreToolUse would read the same way as `{}`. Tests cover both the pure contract and the built bundled script running end to end with no server listening. Also extends the ARG_KEY_MAP test to every mapped key and pins that an explicit canonical key wins over a PascalCase alias. * fix(antigravity): match agy's real hooks.json schema, verified against 1.0.15 Three defects found by probing a live agy 1.0.15 with an instrumented hook, each of which stopped the adapter from capturing anything at all. Lifecycle events take a flat handler list, not the tool-event wrapper. agy parses `PreToolUse`/`PostToolUse` as `[{matcher, hooks: [...]}]` but `PreInvocation`/`PostInvocation`/`Stop` as a bare `[{type, command}]`, since there is no tool name to match on. Wrapping a lifecycle event makes agy read the wrapper itself as a handler and reject the *whole file* with `invalid hook "agentmemory": command hook must specify 'command'` — so the mis-shaped Stop entry disabled every hook in the bundle, and would have disabled hooks other tools had written to the same file. `command` is not run through a shell and quotes are not stripped, so the quoted path resolved to a module name that literally began with a double quote: `Cannot find module 'C:\Users\…\.gemini\config\"C:\…\bridge.mjs"'`. Commands are now bare. That also means a path containing spaces cannot be expressed at all — quoted and unquoted both fail — so the installer refuses with an explanation instead of writing hooks that can only fail at tool time. The merge engine reads both shapes when deciding which bundles agentmemory owns, so a re-install over the old wrapped layout still replaces it rather than leaving a second copy behind. Tests pin both event shapes, the absence of quotes, the space check, and normalization of a payload captured verbatim from the live run — which also confirms `conversationId`, PascalCase `toolCall.args`, and that agy sends no `cwd` key at all. * refactor(antigravity): cut comment volume to match the sibling adapters The bundled script carried 24 comment lines where every other script in plugin/scripts has three. The bundler strips `//` comments but preserves JSDoc blocks, so the fix is to document the bridge's exported helpers with line comments: the explanations stay in source and the generated artifact comes out as clean as its siblings. The connect adapter and merge engine restated the same facts in a file header and again in a per-function block. Kept one statement of each, dropped the repetition, and left the verified agy behaviour in place since that is the part not derivable from the code. --------- Signed-off-by: Bertho Joris <bertho_joris@yahoo.co.id>
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { execFileSync } from "node:child_process";
|
|
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import {
|
|
buildMergedAntigravityHooks,
|
|
containsSpaces,
|
|
type AntigravityHookManifest,
|
|
} from "../src/cli/connect/antigravity-hooks.js";
|
|
import { findPluginRoot } from "../src/cli/connect/codex-hooks.js";
|
|
import {
|
|
normalizePayload,
|
|
responseFor,
|
|
targetsFor,
|
|
} from "../src/hooks/antigravity-bridge.js";
|
|
|
|
const PLUGIN_ROOT = resolve(__dirname, "..", "plugin");
|
|
|
|
function build(existing: AntigravityHookManifest | null = null) {
|
|
return buildMergedAntigravityHooks(existing, findPluginRoot());
|
|
}
|
|
|
|
type Handler = { type: string; command: string; timeout?: number };
|
|
|
|
function eventEntries(bundle: unknown, event: string) {
|
|
return (bundle as Record<string, unknown>)[event] as {
|
|
matcher?: string;
|
|
hooks: Handler[];
|
|
}[];
|
|
}
|
|
|
|
function handlers(bundle: unknown, event: string) {
|
|
return (bundle as Record<string, unknown>)[event] as Handler[];
|
|
}
|
|
|
|
/** Every handler in a bundle, across both of agy's event shapes. */
|
|
function allCommands(bundle: unknown): string[] {
|
|
const out: string[] = [];
|
|
for (const value of Object.values(bundle as Record<string, unknown>)) {
|
|
if (!Array.isArray(value)) continue;
|
|
for (const entry of value) {
|
|
const nested = (entry as { hooks?: Handler[] }).hooks;
|
|
for (const h of nested ?? [entry as Handler]) out.push(h.command);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("buildMergedAntigravityHooks", () => {
|
|
it("rewrites ${CLAUDE_PLUGIN_ROOT} to absolute pluginRoot in every command", () => {
|
|
for (const bundle of Object.values(build())) {
|
|
for (const command of allCommands(bundle)) {
|
|
expect(command).not.toContain("${CLAUDE_PLUGIN_ROOT}");
|
|
expect(command).toContain(`${PLUGIN_ROOT}/scripts/`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("leaves the resolved script path unquoted, as agy's parser requires", () => {
|
|
// agy does not run `command` through a shell and does not strip quotes
|
|
// before splitting, so `node "<root>/x.mjs"` makes node look for a module
|
|
// whose name literally begins with a double quote. Verified on 1.0.15.
|
|
for (const bundle of Object.values(build())) {
|
|
for (const command of allCommands(bundle)) {
|
|
expect(command).not.toContain('"');
|
|
}
|
|
}
|
|
});
|
|
|
|
it("shapes tool events and lifecycle events the way agy parses them", () => {
|
|
// Only tool events take the { matcher, hooks } wrapper. Wrapping a
|
|
// lifecycle event makes agy reject the entire file, which silently
|
|
// disables every other bundle in it too. Verified on agy 1.0.15.
|
|
const bundle = build()["agentmemory"]!;
|
|
for (const event of ["PreToolUse", "PostToolUse"]) {
|
|
for (const entry of eventEntries(bundle, event)) {
|
|
expect(Array.isArray(entry.hooks), event).toBe(true);
|
|
expect(entry, event).not.toHaveProperty("command");
|
|
}
|
|
}
|
|
for (const event of ["PreInvocation", "Stop"]) {
|
|
for (const handler of handlers(bundle, event)) {
|
|
expect(handler.type, event).toBe("command");
|
|
expect(handler.command, event).toContain("antigravity-bridge.mjs");
|
|
expect(handler, event).not.toHaveProperty("hooks");
|
|
expect(handler, event).not.toHaveProperty("matcher");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("flags a plugin path that agy could never execute", () => {
|
|
// Quoted or not, a space truncates the argument — there is no escaping
|
|
// form that works, so the installer has to refuse instead of writing a
|
|
// bundle that loads but never fires.
|
|
expect(containsSpaces("C:/Program Files/agentmemory/plugin")).toBe(true);
|
|
expect(containsSpaces("/opt/agentmemory/plugin")).toBe(false);
|
|
});
|
|
|
|
it("registers under a single named bundle, as Antigravity's schema requires", () => {
|
|
expect(Object.keys(build())).toEqual(["agentmemory"]);
|
|
expect(build()["agentmemory"]!["enabled"]).toBe(true);
|
|
});
|
|
|
|
it("only wires events Antigravity actually dispatches", () => {
|
|
const bundle = build()["agentmemory"]!;
|
|
const events = Object.keys(bundle).filter((k) => k !== "enabled");
|
|
// PostInvocation is intentionally unwired: PostToolUse already captures
|
|
// the work, so firing both would double-record every turn.
|
|
expect(events.sort()).toEqual(
|
|
["PreInvocation", "PreToolUse", "PostToolUse", "Stop"].sort(),
|
|
);
|
|
});
|
|
|
|
it("scopes PreToolUse to the file tools agy actually exposes", () => {
|
|
const matcher = eventEntries(build()["agentmemory"], "PreToolUse")[0]!
|
|
.matcher!;
|
|
for (const tool of ["view_file", "edit_file", "write_to_file", "grep_search"]) {
|
|
expect(matcher.split("|")).toContain(tool);
|
|
}
|
|
// run_command is deliberately excluded — shell invocations are captured
|
|
// on PostToolUse, and matching them here would fire on every command.
|
|
expect(matcher.split("|")).not.toContain("run_command");
|
|
});
|
|
|
|
it("keeps user-authored hook bundles untouched", () => {
|
|
const existing: AntigravityHookManifest = {
|
|
"block-run-command": {
|
|
enabled: true,
|
|
PreToolUse: [
|
|
{
|
|
matcher: "run_command",
|
|
hooks: [{ type: "command", command: "/usr/local/bin/deny.sh" }],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const merged = build(existing);
|
|
expect(merged["block-run-command"]).toEqual(existing["block-run-command"]);
|
|
expect(merged["agentmemory"]).toBeDefined();
|
|
});
|
|
|
|
it("replaces a stale agentmemory bundle instead of duplicating it", () => {
|
|
const stale: AntigravityHookManifest = {
|
|
"agentmemory-legacy": {
|
|
enabled: true,
|
|
Stop: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: "command",
|
|
command: `node "${PLUGIN_ROOT}/scripts/removed-hook.mjs" Stop`,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const merged = build(stale);
|
|
expect(merged["agentmemory-legacy"]).toBeUndefined();
|
|
expect(Object.keys(merged)).toEqual(["agentmemory"]);
|
|
});
|
|
|
|
it("recognises a stale bundle written in the flat lifecycle shape too", () => {
|
|
// Ownership detection has to see through both shapes, or a re-install
|
|
// leaves the old bundle behind and agy runs two copies of every hook.
|
|
const stale: AntigravityHookManifest = {
|
|
"agentmemory-legacy": {
|
|
enabled: true,
|
|
Stop: [
|
|
{
|
|
type: "command",
|
|
command: `node ${PLUGIN_ROOT}/scripts/removed-hook.mjs Stop`,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
expect(build(stale)["agentmemory-legacy"]).toBeUndefined();
|
|
});
|
|
|
|
it("re-install is idempotent", () => {
|
|
const first = build();
|
|
expect(build(first)).toEqual(first);
|
|
});
|
|
|
|
it("keeps a pluginRoot containing $-replacement patterns literal", () => {
|
|
// `String.prototype.replace` with a string argument reads `$$`, `$&`,
|
|
// "$`" and `$'` in the replacement as patterns. An install path holding
|
|
// any of them would otherwise be rewritten into a broken command, and
|
|
// the only symptom would be hooks that silently never fire.
|
|
const tmp = mkdtempSync(join(tmpdir(), "am-antigravity-"));
|
|
try {
|
|
const oddRoot = join(tmp, "plug$&$$in");
|
|
mkdirSync(join(oddRoot, "hooks"), { recursive: true });
|
|
copyFileSync(
|
|
join(PLUGIN_ROOT, "hooks", "hooks.antigravity.json"),
|
|
join(oddRoot, "hooks", "hooks.antigravity.json"),
|
|
);
|
|
|
|
const command = handlers(
|
|
buildMergedAntigravityHooks(null, oddRoot)["agentmemory"],
|
|
"Stop",
|
|
)[0]!.command;
|
|
|
|
expect(command).toContain(`${oddRoot}/scripts/`);
|
|
expect(command).not.toContain("${CLAUDE_PLUGIN_ROOT}");
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("antigravity bridge payload normalization", () => {
|
|
it("maps conversationId and workspacePaths onto the canonical fields", () => {
|
|
const out = normalizePayload("PostToolUse", {
|
|
conversationId: "conv_123",
|
|
workspacePaths: ["/repo/app"],
|
|
transcriptPath: "/tmp/t.jsonl",
|
|
});
|
|
expect(out["session_id"]).toBe("conv_123");
|
|
expect(out["cwd"]).toBe("/repo/app");
|
|
expect(out["transcript_path"]).toBe("/tmp/t.jsonl");
|
|
expect(out["hook_event_name"]).toBe("PostToolUse");
|
|
});
|
|
|
|
it("flattens toolCall into tool_name/tool_input with Cascade names mapped", () => {
|
|
const out = normalizePayload("PreToolUse", {
|
|
conversationId: "c1",
|
|
toolCall: {
|
|
name: "view_file",
|
|
args: { AbsolutePath: "/repo/src/index.ts", StartLine: 1 },
|
|
},
|
|
});
|
|
expect(out["tool_name"]).toBe("read");
|
|
expect(out["native_tool_name"]).toBe("view_file");
|
|
expect((out["tool_input"] as Record<string, unknown>)["file_path"]).toBe(
|
|
"/repo/src/index.ts",
|
|
);
|
|
// Original PascalCase args survive for anything downstream that wants them.
|
|
expect((out["tool_input"] as Record<string, unknown>)["StartLine"]).toBe(1);
|
|
});
|
|
|
|
it("normalizes a payload captured verbatim from agy 1.0.15", () => {
|
|
// Recorded by pointing a probe hook at a live `agy --print` run. Note
|
|
// there is no `cwd` key at all, and `workspacePaths` came back empty in
|
|
// headless mode — the session id has to come from `conversationId`.
|
|
const out = normalizePayload("PreToolUse", {
|
|
artifactDirectoryPath:
|
|
"C:/Users/u/.gemini/antigravity-cli/brain/53642203-62f2-45e9-bda3-b1304c61bc99",
|
|
conversationId: "53642203-62f2-45e9-bda3-b1304c61bc99",
|
|
modelName: "gemini-3.6-flash-high",
|
|
stepIdx: 3,
|
|
toolCall: {
|
|
args: { DirectoryPath: "C:\\Users\\u\\.gemini\\antigravity-cli" },
|
|
name: "list_dir",
|
|
},
|
|
transcriptPath:
|
|
"C:/Users/u/.gemini/antigravity-cli/brain/53642203/.system_generated/logs/transcript_full.jsonl",
|
|
workspacePaths: [],
|
|
});
|
|
|
|
expect(out["session_id"]).toBe("53642203-62f2-45e9-bda3-b1304c61bc99");
|
|
expect(out["tool_name"]).toBe("glob");
|
|
expect(out["native_tool_name"]).toBe("list_dir");
|
|
expect((out["tool_input"] as Record<string, unknown>)["path"]).toBe(
|
|
"C:\\Users\\u\\.gemini\\antigravity-cli",
|
|
);
|
|
expect(out["transcript_path"]).toContain("transcript_full.jsonl");
|
|
// Fields agy sends that no bundled hook reads still survive the trip.
|
|
expect(out["modelName"]).toBe("gemini-3.6-flash-high");
|
|
expect(out["stepIdx"]).toBe(3);
|
|
});
|
|
|
|
it("maps every PascalCase arg the canonical hooks read", () => {
|
|
const cases: [string, string, string][] = [
|
|
["AbsolutePath", "file_path", "/repo/a.ts"],
|
|
["TargetFile", "file_path", "/repo/b.ts"],
|
|
["DirectoryPath", "path", "/repo/src"],
|
|
["SearchDirectory", "path", "/repo/test"],
|
|
["Pattern", "pattern", "*.ts"],
|
|
["Query", "pattern", "normalizePayload"],
|
|
["CommandLine", "command", "npm test"],
|
|
];
|
|
for (const [from, to, value] of cases) {
|
|
const input = normalizePayload("PreToolUse", {
|
|
toolCall: { name: "view_file", args: { [from]: value } },
|
|
})["tool_input"] as Record<string, unknown>;
|
|
expect(input[to], `${from} -> ${to}`).toBe(value);
|
|
// The original key survives alongside the canonical one.
|
|
expect(input[from], from).toBe(value);
|
|
}
|
|
});
|
|
|
|
it("does not let a mapped alias clobber an explicit canonical key", () => {
|
|
const input = normalizePayload("PreToolUse", {
|
|
toolCall: {
|
|
name: "edit_file",
|
|
args: { TargetFile: "/repo/alias.ts", file_path: "/repo/explicit.ts" },
|
|
},
|
|
})["tool_input"] as Record<string, unknown>;
|
|
expect(input["file_path"]).toBe("/repo/explicit.ts");
|
|
});
|
|
|
|
it("passes unmapped tool names through unchanged", () => {
|
|
const out = normalizePayload("PostToolUse", {
|
|
toolCall: { name: "run_command", args: { CommandLine: "npm test" } },
|
|
});
|
|
expect(out["tool_name"]).toBe("run_command");
|
|
expect((out["tool_input"] as Record<string, unknown>)["command"]).toBe(
|
|
"npm test",
|
|
);
|
|
});
|
|
|
|
it("falls back to a placeholder session id rather than dropping the event", () => {
|
|
expect(normalizePayload("Stop", {})["session_id"]).toBe("unknown");
|
|
});
|
|
});
|
|
|
|
describe("antigravity bridge stdout contract", () => {
|
|
// agy documents `decision` as required on PreToolUse output and treats a
|
|
// response without it as a denial — a bare `{}` there makes the agent
|
|
// refuse every matched tool call instead of passively capturing it.
|
|
it("answers PreToolUse with an explicit allow, everything else with {}", () => {
|
|
expect(JSON.parse(responseFor("PreToolUse"))).toEqual({
|
|
decision: "allow",
|
|
});
|
|
for (const event of ["PreInvocation", "PostToolUse", "Stop", ""]) {
|
|
expect(JSON.parse(responseFor(event)), event).toEqual({});
|
|
}
|
|
});
|
|
|
|
it("writes that contract to stdout when the bundled script actually runs", () => {
|
|
const script = join(PLUGIN_ROOT, "scripts", "antigravity-bridge.mjs");
|
|
const run = (event: string) =>
|
|
execFileSync(process.execPath, [script, event], {
|
|
input: JSON.stringify({
|
|
conversationId: "c1",
|
|
toolCall: { name: "view_file", args: { AbsolutePath: "/repo/a.ts" } },
|
|
}),
|
|
encoding: "utf-8",
|
|
// No server is listening on port 1, so every capture fetch fails
|
|
// fast: this asserts the response survives a failed capture, which
|
|
// is exactly the case where a swallowed error could emit nothing.
|
|
env: { ...process.env, AGENTMEMORY_URL: "http://127.0.0.1:1" },
|
|
stdio: ["pipe", "pipe", "ignore"],
|
|
});
|
|
|
|
expect(JSON.parse(run("PreToolUse"))).toEqual({ decision: "allow" });
|
|
expect(JSON.parse(run("PostToolUse"))).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("antigravity bridge event routing", () => {
|
|
it("opens the session on the first invocation only", () => {
|
|
expect(targetsFor("PreInvocation", { invocationNum: 1 })).toEqual([
|
|
"session-start.mjs",
|
|
"prompt-submit.mjs",
|
|
]);
|
|
expect(targetsFor("PreInvocation", { invocationNum: 4 })).toEqual([
|
|
"prompt-submit.mjs",
|
|
]);
|
|
});
|
|
|
|
it("treats a missing invocationNum as the first invocation", () => {
|
|
expect(targetsFor("PreInvocation", {})).toContain("session-start.mjs");
|
|
});
|
|
|
|
it("closes the session on Stop", () => {
|
|
expect(targetsFor("Stop", {})).toEqual(["stop.mjs", "session-end.mjs"]);
|
|
});
|
|
|
|
it("ignores PostInvocation to avoid double-capturing a turn", () => {
|
|
expect(targetsFor("PostInvocation", {})).toEqual([]);
|
|
});
|
|
});
|