agent-orchestrator/packages/plugins/agent-claude-code/src/index.test.ts

1317 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { join as pathJoin } from "node:path";
import {
createActivitySignal,
type Session,
type RuntimeHandle,
type AgentLaunchConfig,
type WorkspaceHooksConfig,
} from "@aoagents/ao-core";
// ---------------------------------------------------------------------------
// Hoisted mocks — available inside vi.mock factories
// ---------------------------------------------------------------------------
const {
mockExecFileAsync,
mockReaddir,
mockReadFile,
mockReadFileSync,
mockStat,
mockHomedir,
mockWriteFile,
mockMkdir,
mockChmod,
mockExistsSync,
mockIsWindows,
} = vi.hoisted(() => ({
mockExecFileAsync: vi.fn(),
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockReadFileSync: vi.fn(() => ""),
mockStat: vi.fn(),
mockHomedir: vi.fn(() => "/mock/home"),
mockWriteFile: vi.fn().mockResolvedValue(undefined),
mockMkdir: vi.fn().mockResolvedValue(undefined),
mockChmod: vi.fn().mockResolvedValue(undefined),
mockExistsSync: vi.fn().mockReturnValue(false),
mockIsWindows: vi.fn(() => false),
}));
vi.mock("node:child_process", () => {
const fn = Object.assign((..._args: unknown[]) => {}, {
[Symbol.for("nodejs.util.promisify.custom")]: mockExecFileAsync,
});
return { execFile: fn };
});
vi.mock("node:fs/promises", () => ({
readdir: mockReaddir,
readFile: mockReadFile,
stat: mockStat,
writeFile: mockWriteFile,
mkdir: mockMkdir,
chmod: mockChmod,
}));
vi.mock("node:fs", () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
}));
vi.mock("node:os", () => ({
homedir: mockHomedir,
}));
vi.mock("@aoagents/ao-core", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
isWindows: mockIsWindows,
};
});
import {
create,
manifest,
default as defaultExport,
resetPsCache,
toClaudeProjectPath,
METADATA_UPDATER_SCRIPT,
METADATA_UPDATER_SCRIPT_NODE,
} from "./index.js";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: "test-1",
projectId: "test-project",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
branch: "feat/test",
issueId: null,
pr: null,
workspacePath: "/workspace/test-project",
runtimeHandle: null,
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
...overrides,
};
}
function makeTmuxHandle(id = "test-session"): RuntimeHandle {
return { id, runtimeName: "tmux", data: {} };
}
function makeProcessHandle(pid?: number): RuntimeHandle {
return { id: "proc-1", runtimeName: "process", data: pid !== undefined ? { pid } : {} };
}
function makeLaunchConfig(overrides: Partial<AgentLaunchConfig> = {}): AgentLaunchConfig {
return {
sessionId: "sess-1",
projectConfig: {
name: "my-project",
repo: "owner/repo",
path: "/workspace/repo",
defaultBranch: "main",
sessionPrefix: "my",
},
...overrides,
};
}
function mockTmuxWithProcess(processName = "claude", tty = "/dev/ttys001", pid = 12345) {
mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "tmux" && args[0] === "list-panes") {
return Promise.resolve({ stdout: `${tty}\n`, stderr: "" });
}
if (cmd === "ps") {
const ttyShort = tty.replace(/^\/dev\//, "");
// Matches `ps -eo pid,tty,args` output format
return Promise.resolve({
stdout: ` PID TT ARGS\n ${pid} ${ttyShort} ${processName}\n`,
stderr: "",
});
}
return Promise.reject(new Error(`Unexpected: ${cmd} ${args.join(" ")}`));
});
}
function mockJsonlFiles(
jsonlContent: string,
files = ["session-abc123.jsonl"],
mtime = new Date(1700000000000),
) {
mockReaddir.mockResolvedValue(files);
mockStat.mockResolvedValue({ mtimeMs: mtime.getTime(), mtime });
mockReadFile.mockResolvedValue(jsonlContent);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
resetPsCache();
mockHomedir.mockReturnValue("/mock/home");
// Default: non-Windows so existing tests are unaffected
mockIsWindows.mockReturnValue(false);
});
describe("toClaudeProjectPath", () => {
it("encodes a plain unix path", () => {
expect(toClaudeProjectPath("/Users/dev/projects/foo")).toBe("-Users-dev-projects-foo");
});
it("collapses dot directories like .worktrees into a leading double dash", () => {
expect(toClaudeProjectPath("/Users/dev/.worktrees/ao/ao-3")).toBe(
"-Users-dev--worktrees-ao-ao-3",
);
});
it("normalizes underscores to dashes (issue #1611)", () => {
// AO project data dirs are named `<sanitized>_<hash>`. Claude Code converts
// underscores to dashes when computing its on-disk project slug; without
// matching that here the slug points to a non-existent directory and
// restore loses the conversation.
expect(
toClaudeProjectPath(
"/Users/dev/.agent-orchestrator/projects/graph-isomorphism_d185b44d56/worktrees/gi-orchestrator",
),
).toBe(
"-Users-dev--agent-orchestrator-projects-graph-isomorphism-d185b44d56-worktrees-gi-orchestrator",
);
});
it("encodes Windows drive colons and backslashes as dashes", () => {
// Verified on-disk: Claude Code on Windows produces `C--Users-dev-foo`
// (the colon position is a dash, not stripped). See commit 582c5373.
expect(toClaudeProjectPath("C:\\Users\\dev\\foo")).toBe("C--Users-dev-foo");
});
it("collapses any other non-alphanumeric character into a dash", () => {
expect(toClaudeProjectPath("/Users/dev/proj@v2/foo bar")).toBe("-Users-dev-proj-v2-foo-bar");
});
});
describe("plugin manifest & exports", () => {
it("has correct manifest", () => {
expect(manifest).toEqual({
name: "claude-code",
slot: "agent",
description: "Agent plugin: Claude Code CLI",
version: "0.1.0",
displayName: "Claude Code",
});
});
it("create() returns an agent with correct name and processName", () => {
const agent = create();
expect(agent.name).toBe("claude-code");
expect(agent.processName).toBe("claude");
});
it("default export is a valid PluginModule", () => {
expect(defaultExport.manifest).toBe(manifest);
expect(typeof defaultExport.create).toBe("function");
});
});
// =========================================================================
// getLaunchCommand
// =========================================================================
describe("getLaunchCommand", () => {
const agent = create();
it("generates base command without shell syntax", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "default" }));
expect(cmd).toBe("claude");
// Must not contain shell operators (execFile-safe)
expect(cmd).not.toContain("&&");
expect(cmd).not.toContain("unset");
});
it("includes --dangerously-skip-permissions when permissions=permissionless", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "permissionless" }));
expect(cmd).toContain("--dangerously-skip-permissions");
});
it("treats legacy permissions=skip as permissionless", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "skip" as unknown as AgentLaunchConfig["permissions"] }),
);
expect(cmd).toContain("--dangerously-skip-permissions");
});
it("maps permissions=auto-edit to no-prompt mode on Claude", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "auto-edit" }));
expect(cmd).toContain("--dangerously-skip-permissions");
});
it("shell-escapes model argument", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "claude-opus-4-6" }));
expect(cmd).toContain("--model 'claude-opus-4-6'");
});
it("includes prompt as positional argument with -- separator (not -p flag)", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix the bug" }));
expect(cmd).not.toContain("-p");
expect(cmd).toContain("-- 'Fix the bug'");
});
it("combines all options with prompt as positional arg after --", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "permissionless", model: "opus", prompt: "Hello" }),
);
expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus' -- 'Hello'");
});
it("handles prompts starting with dashes safely via -- separator", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "--investigate this" }));
expect(cmd).toContain("-- '--investigate this'");
});
it("omits --dangerously-skip-permissions when permissions=default", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "default" }));
expect(cmd).not.toContain("--dangerously-skip-permissions");
});
it("omits optional flags when not provided", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig());
expect(cmd).not.toContain("--model");
expect(cmd).not.toContain("-p");
});
it("includes --append-system-prompt and prompt as positional arg after --", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ systemPrompt: "You are a helper", prompt: "Do the task" }),
);
expect(cmd).toContain("--append-system-prompt");
expect(cmd).toContain("You are a helper");
expect(cmd).not.toMatch(/\s-p\s/);
expect(cmd).toContain("-- 'Do the task'");
});
it("uses systemPromptFile via shell substitution with prompt after --", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md", prompt: "Do the task" }),
);
expect(cmd).toContain('--append-system-prompt "$(cat');
expect(cmd).toContain("/tmp/prompt.md");
expect(cmd).not.toMatch(/\s-p\s/);
expect(cmd).toContain("-- 'Do the task'");
});
it("inlines systemPromptFile content on Windows instead of $(cat ...)", () => {
mockIsWindows.mockReturnValueOnce(true);
mockReadFileSync.mockReturnValueOnce("You are a helpful assistant.");
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ systemPromptFile: "C:\\prompts\\system.md", prompt: "Do the task" }),
);
expect(cmd).toContain("--append-system-prompt");
expect(cmd).toContain("You are a helpful assistant.");
expect(cmd).not.toContain("$(cat");
});
});
// =========================================================================
// getEnvironment
// =========================================================================
describe("getEnvironment", () => {
const agent = create();
it("sets CLAUDECODE to empty string (replaces unset in command)", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["CLAUDECODE"]).toBe("");
});
it("sets AO_SESSION_ID but not AO_PROJECT_ID (caller's responsibility)", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_SESSION_ID"]).toBe("sess-1");
expect(env["AO_PROJECT_ID"]).toBeUndefined();
});
it("sets AO_ISSUE_ID when provided", () => {
const env = agent.getEnvironment(makeLaunchConfig({ issueId: "INT-100" }));
expect(env["AO_ISSUE_ID"]).toBe("INT-100");
});
it("does not set AO_ISSUE_ID when not provided", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_ISSUE_ID"]).toBeUndefined();
});
});
// =========================================================================
// isProcessRunning
// =========================================================================
describe("isProcessRunning", () => {
const agent = create();
it("returns true when claude is found on tmux pane TTY", async () => {
mockTmuxWithProcess("claude");
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
});
it("returns false when no claude on tmux pane TTY", async () => {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys002\n", stderr: "" });
if (cmd === "ps")
return Promise.resolve({
stdout: " PID TT ARGS\n 999 ttys002 bash\n",
stderr: "",
});
return Promise.reject(new Error("unexpected"));
});
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
});
// Coverage for the broadened process regex — these are real install shapes
// the previous narrow regex `/(?:^|\/)claude(?:\s|$)/` would have missed,
// causing AO to declare sessions `exited` while Claude was still running.
it.each([
["bare binary", "claude"],
["absolute path", "/opt/homebrew/bin/claude"],
["dot-prefix shim", "/usr/local/lib/.claude"],
["windows exe", "claude.exe"],
["js shim", "claude.js"],
["hyphenated name", "claude-code"],
["node-shim npm install", "node /opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js"],
])("returns true for %s (%s)", async (_label, args) => {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" });
if (cmd === "ps")
return Promise.resolve({
stdout: ` PID TT ARGS\n 123 ttys001 ${args}\n`,
stderr: "",
});
return Promise.reject(new Error("unexpected"));
});
resetPsCache();
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
});
it("still rejects look-alike names (claudia, claudine)", async () => {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" });
if (cmd === "ps")
return Promise.resolve({
stdout: " PID TT ARGS\n 123 ttys001 claudia\n 124 ttys001 /bin/claudine\n",
stderr: "",
});
return Promise.reject(new Error("unexpected"));
});
resetPsCache();
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
});
it("returns false when tmux list-panes returns empty", async () => {
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" });
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
});
it("returns true for process runtime with alive PID", async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
expect(await agent.isProcessRunning(makeProcessHandle(999))).toBe(true);
expect(killSpy).toHaveBeenCalledWith(999, 0);
killSpy.mockRestore();
});
it("returns false for process runtime with dead PID", async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw new Error("ESRCH");
});
expect(await agent.isProcessRunning(makeProcessHandle(999))).toBe(false);
killSpy.mockRestore();
});
it("returns false for unknown runtime without PID (no pgrep fallback)", async () => {
const handle: RuntimeHandle = { id: "x", runtimeName: "other", data: {} };
expect(await agent.isProcessRunning(handle)).toBe(false);
// Must NOT call pgrep — could match wrong session
expect(mockExecFileAsync).not.toHaveBeenCalled();
});
it("returns indeterminate when tmux command fails", async () => {
mockExecFileAsync.mockRejectedValue(new Error("fail"));
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate");
});
it("returns indeterminate when cached ps command fails", async () => {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys002\n", stderr: "" });
if (cmd === "ps") return Promise.reject(new Error("ps timed out"));
return Promise.reject(new Error("unexpected"));
});
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate");
});
it("returns true when PID exists but throws EPERM", async () => {
const epermErr = Object.assign(new Error("EPERM"), { code: "EPERM" });
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw epermErr;
});
expect(await agent.isProcessRunning(makeProcessHandle(789))).toBe(true);
killSpy.mockRestore();
});
it("finds claude on any pane in multi-pane session", async () => {
mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "tmux" && args[0] === "list-panes") {
return Promise.resolve({ stdout: "/dev/ttys001\n/dev/ttys002\n", stderr: "" });
}
if (cmd === "ps") {
return Promise.resolve({
stdout: " PID TT ARGS\n 100 ttys001 bash\n 200 ttys002 claude -p test\n",
stderr: "",
});
}
return Promise.reject(new Error("unexpected"));
});
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
});
it("returns false for tmux handle on Windows without spawning ps", async () => {
mockIsWindows.mockReturnValue(true);
// ps should never be called — getCachedProcessList guards against Windows
mockExecFileAsync.mockRejectedValue(new Error("ps not available on Windows"));
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
expect(mockExecFileAsync).not.toHaveBeenCalledWith("ps", expect.anything(), expect.anything());
mockIsWindows.mockReturnValue(false);
});
});
// =========================================================================
// detectActivity — terminal output classification
// =========================================================================
describe("detectActivity", () => {
const agent = create();
it("returns idle for empty terminal output", () => {
expect(agent.detectActivity("")).toBe("idle");
});
it("returns idle for whitespace-only terminal output", () => {
expect(agent.detectActivity(" \n \n ")).toBe("idle");
});
it("returns active when 'esc to interrupt' is visible", () => {
expect(agent.detectActivity("Working... esc to interrupt\n")).toBe("active");
});
it("returns active when Thinking indicator is visible", () => {
expect(agent.detectActivity("Thinking...\n")).toBe("active");
});
it("returns active when Reading indicator is visible", () => {
expect(agent.detectActivity("Reading file src/index.ts\n")).toBe("active");
});
it("returns active when Writing indicator is visible", () => {
expect(agent.detectActivity("Writing to src/main.ts\n")).toBe("active");
});
it("returns active when Searching indicator is visible", () => {
expect(agent.detectActivity("Searching codebase...\n")).toBe("active");
});
it("returns waiting_input for permission prompt (Y/N)", () => {
expect(agent.detectActivity("Do you want to proceed? (Y)es / (N)o\n")).toBe("waiting_input");
});
it("returns waiting_input for 'Do you want to proceed?' prompt", () => {
expect(agent.detectActivity("Do you want to proceed?\n")).toBe("waiting_input");
});
it("returns waiting_input for bypass permissions prompt", () => {
expect(agent.detectActivity("bypass all future permissions for this session\n")).toBe(
"waiting_input",
);
});
it("does NOT match Claude's persistent UI footer 'bypass permissions on (shift+tab to cycle)'", () => {
// Regression test: the old `/bypass.*permissions/i` regex matched this
// footer toggle (visible on EVERY Claude session) and falsely fired
// waiting_input for every session that fell through to the AO JSONL
// pipeline. ao-143/144/151 all flipped to waiting_input on dormant
// sessions until this was tightened to require "all future".
const footerOnly = [
"✻ Crunched for 11s",
"",
"──────────────────────────────────────────────────────────",
" ",
"──────────────────────────────────────────────────────────",
" ⏵⏵ bypass permissions on (shift+tab to cycle)",
].join("\n");
expect(agent.detectActivity(footerOnly)).not.toBe("waiting_input");
});
it("returns active when queued message indicator is visible", () => {
expect(agent.detectActivity("Press up to edit queued messages\n")).toBe("active");
});
it("returns idle when shell prompt is visible", () => {
expect(agent.detectActivity("some output\n> ")).toBe("idle");
expect(agent.detectActivity("some output\n$ ")).toBe("idle");
});
it("returns idle when prompt follows historical activity indicators", () => {
// Key regression test: historical "Reading file..." output in the buffer
// should NOT override an idle prompt on the last line.
expect(agent.detectActivity("Reading file src/index.ts\nWriting to out.ts\n ")).toBe("idle");
expect(agent.detectActivity("Thinking...\nSearching codebase...\n$ ")).toBe("idle");
});
it("returns waiting_input when permission prompt follows historical activity", () => {
// Permission prompt at the bottom should NOT be overridden by historical
// "Reading"/"Thinking" output higher in the buffer.
expect(
agent.detectActivity("Reading file src/index.ts\nThinking...\nDo you want to proceed?\n"),
).toBe("waiting_input");
expect(agent.detectActivity("Searching codebase...\n(Y)es / (N)o\n")).toBe("waiting_input");
expect(
agent.detectActivity("Writing to out.ts\nbypass all future permissions for this session\n"),
).toBe("waiting_input");
});
it("returns idle for non-empty output with no active-work indicators", () => {
// Default-to-idle (changed from default-to-active in this PR). Claude's
// tmux pane has a persistent input area + footer that looks identical
// between "just finished" and "currently working". Treating
// unrecognized output as active caused dormant sessions to get an
// "active" written to AO activity-JSONL every poll cycle, which the
// age-decayed fallback then surfaced as ready forever (ao-160 repro).
expect(agent.detectActivity("some random terminal output\n")).toBe("idle");
});
it("returns idle for dormant session showing only Claude's input area + footer", () => {
// Real captured output from a dormant session (ao-143 style): assistant
// output above, separator, empty prompt line, separator, footer toggle.
// The empty prompt is NOT the LAST line (footer is) so the existing
// lastLine check misses it, and previously the default-to-active sent
// every dormant session into the AO-JSONL active-loop.
const dormant = [
"※ recap: working on issue #143; next: wait for review",
"",
"──────────────────────────────────────────────────────────",
" ",
"──────────────────────────────────────────────────────────",
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
].join("\n");
expect(agent.detectActivity(dormant)).toBe("idle");
});
it("returns active when spinner+ellipsis is in the tail (✻ Fluttering…)", () => {
// Real captured output from ao-161 mid-active-turn. The ✻ spinner
// followed by a verb and trailing ellipsis is the canonical Claude
// active indicator across all turn-status words (Germinating,
// Fluttering, Thinking, Pondering, etc).
const active = [
"✻ Fluttering… (6m 49s · ↓ 26.9k tokens)",
" ⎿ Tip: Use /feedback to help us improve!",
"",
"──────",
" ",
"──────",
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
].join("\n");
expect(agent.detectActivity(active)).toBe("active");
});
it("returns idle for past-tense spinner status like '✻ Worked for 11s' (no ellipsis)", () => {
// Real captured output from ao-143 dormant. The ✻ glyph appears in
// past-tense turn summaries too — without the trailing ellipsis,
// Claude is done, not active.
const dormant = [
"⏺ Posted: https://github.com/owner/repo/pull/1#comment-1",
"",
"✻ Worked for 11s",
"",
"※ recap: working on issue #143; next: wait for review",
"──────",
" ",
"──────",
" ⏵⏵ bypass permissions on (shift+tab to cycle)",
].join("\n");
expect(agent.detectActivity(dormant)).toBe("idle");
});
// Blocked detection from terminal regex — empirically captured from real
// Claude output during api.anthropic.com block (see PR #1932).
it("returns blocked for 'Unable to connect to API' error line", () => {
const real = [
" what is 2+2? answer in one word.",
" ⎿ Unable to connect to API (ConnectionRefused)",
" Retrying in 19s · attempt 7/10",
"",
"✽ Germinating… (56s)",
"",
].join("\n");
expect(agent.detectActivity(real)).toBe("blocked");
});
it("returns blocked for FailedToOpenSocket error variant", () => {
expect(
agent.detectActivity(" ⎿ Unable to connect to API (FailedToOpenSocket)\n"),
).toBe("blocked");
});
it("returns blocked for retry counter alone (Retrying in Ns · attempt N/M)", () => {
// If only the retry line is in the visible window (error scrolled off),
// the retry counter is still a sufficient signal.
expect(agent.detectActivity(" Retrying in 30s · attempt 9/10\n")).toBe("blocked");
});
it("does NOT return blocked when API error has scrolled out of the visible window after a successful retry", () => {
// Regression test: blocked detection must be bounded to the last 12
// lines (wideTail), NOT the full terminalOutput buffer. Otherwise an
// api_error that scrolled off the visible area after a successful
// retry but stayed in scrollback would falsely return "blocked"
// forever (Greptile review on PR #1932).
const recoveredAndContinued = [
" ⎿ Unable to connect to API (ConnectionRefused)",
" Retrying in 1s · attempt 1/10",
" ⎿ ✓ Connected, retry succeeded",
"",
"(many lines of work output below pushing the error off the visible area)",
...Array.from({ length: 15 }, (_, i) => ` line ${i + 1} of subsequent work`),
"",
"✻ Fluttering… (2m 14s)",
" ⎿ Tip: Use /feedback to help us improve!",
"",
"──────",
" ",
"──────",
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
].join("\n");
expect(agent.detectActivity(recoveredAndContinued)).toBe("active");
});
it("blocked takes precedence over waiting_input when both 'bypass permissions' footer and api-error are present", () => {
// Claude's static UI footer always contains "bypass permissions on …",
// which the existing waiting_input regex matches. A real blocked state
// must win over that incidental match.
const real = [
" ⎿ Unable to connect to API (ConnectionRefused)",
" Retrying in 1s · attempt 5/10",
"",
"────────────────────────────────────────",
" ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt",
].join("\n");
expect(agent.detectActivity(real)).toBe("blocked");
});
});
// =========================================================================
// getSessionInfo — JSONL parsing
// =========================================================================
describe("getSessionInfo", () => {
const agent = create();
it("returns null when workspacePath is null", async () => {
expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull();
});
it("returns null when project directory does not exist", async () => {
mockReaddir.mockRejectedValue(new Error("ENOENT"));
expect(await agent.getSessionInfo(makeSession())).toBeNull();
});
it("returns null when no JSONL files in project dir", async () => {
mockReaddir.mockResolvedValue(["readme.txt", "config.yaml"]);
expect(await agent.getSessionInfo(makeSession())).toBeNull();
});
it("filters out agent- prefixed JSONL files", async () => {
mockReaddir.mockResolvedValue(["agent-toolkit.jsonl"]);
expect(await agent.getSessionInfo(makeSession())).toBeNull();
});
it("returns null when JSONL file is empty", async () => {
mockJsonlFiles("");
expect(await agent.getSessionInfo(makeSession())).toBeNull();
});
it("returns null when JSONL has only malformed lines", async () => {
mockJsonlFiles("not json\nalso not json\n");
expect(await agent.getSessionInfo(makeSession())).toBeNull();
});
describe("path conversion", () => {
it("converts workspace path to Claude project dir path", async () => {
mockJsonlFiles('{"type":"user","message":{"content":"hello"}}');
await agent.getSessionInfo(makeSession({ workspacePath: "/Users/dev/.worktrees/ao/ao-3" }));
expect(mockReaddir).toHaveBeenCalledWith(
pathJoin("/mock/home", ".claude", "projects", "-Users-dev--worktrees-ao-ao-3"),
);
});
it("normalizes underscores to dashes (matches Claude Code on-disk slug, issue #1611)", async () => {
mockJsonlFiles('{"type":"user","message":{"content":"hello"}}');
await agent.getSessionInfo(
makeSession({
workspacePath:
"/Users/dev/.agent-orchestrator/projects/graph-isomorphism_d185b44d56/worktrees/gi-orchestrator",
}),
);
expect(mockReaddir).toHaveBeenCalledWith(
pathJoin(
"/mock/home",
".claude",
"projects",
"-Users-dev--agent-orchestrator-projects-graph-isomorphism-d185b44d56-worktrees-gi-orchestrator",
),
);
});
});
describe("summary extraction", () => {
it("extracts summary from last summary event and marks as not fallback", async () => {
const jsonl = [
'{"type":"summary","summary":"First summary"}',
'{"type":"user","message":{"content":"do something"}}',
'{"type":"summary","summary":"Latest summary"}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.summary).toBe("Latest summary");
expect(result?.summaryIsFallback).toBe(false);
});
it("falls back to first user message and marks as fallback", async () => {
const jsonl = [
'{"type":"user","message":{"content":"Implement the login feature"}}',
'{"type":"assistant","message":{"content":"I will implement..."}}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.summary).toBe("Implement the login feature");
expect(result?.summaryIsFallback).toBe(true);
});
it("truncates long user message to 120 chars", async () => {
const longMsg = "A".repeat(200);
const jsonl = `{"type":"user","message":{"content":"${longMsg}"}}`;
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.summary).toBe("A".repeat(120) + "...");
expect(result!.summary!.length).toBe(123);
expect(result?.summaryIsFallback).toBe(true);
});
it("returns null summary when no summary and no user messages", async () => {
const jsonl = '{"type":"assistant","message":{"content":"Hello"}}';
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.summary).toBeNull();
expect(result?.summaryIsFallback).toBeUndefined();
});
it("skips user messages with empty content", async () => {
const jsonl = [
'{"type":"user","message":{"content":" "}}',
'{"type":"user","message":{"content":"Real content"}}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.summary).toBe("Real content");
expect(result?.summaryIsFallback).toBe(true);
});
});
describe("session ID extraction", () => {
it("extracts session ID from filename", async () => {
mockJsonlFiles('{"type":"user","message":{"content":"hi"}}', ["abc-def-123.jsonl"]);
const result = await agent.getSessionInfo(makeSession());
expect(result?.agentSessionId).toBe("abc-def-123");
expect(result?.metadata?.claudeSessionUuid).toBe("abc-def-123");
});
});
describe("getRestoreCommand metadata", () => {
it("uses persisted Claude session UUID without scanning project files", async () => {
const agent = create();
const session = makeSession({
workspacePath: "/workspace/test-project",
metadata: { claudeSessionUuid: "persisted-uuid" },
});
const command = await agent.getRestoreCommand!(session, {
name: "test-project",
repo: "owner/repo",
path: "/workspace/test-project",
defaultBranch: "main",
sessionPrefix: "test",
});
expect(command).toBe("claude --resume 'persisted-uuid'");
expect(mockReaddir).not.toHaveBeenCalled();
});
});
describe("cost estimation", () => {
it("aggregates usage.input_tokens and usage.output_tokens", async () => {
const jsonl = [
'{"type":"user","message":{"content":"hi"}}',
'{"type":"assistant","usage":{"input_tokens":1000,"output_tokens":500}}',
'{"type":"assistant","usage":{"input_tokens":2000,"output_tokens":300}}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.cost?.inputTokens).toBe(3000);
expect(result?.cost?.outputTokens).toBe(800);
expect(result?.cost?.estimatedCostUsd).toBeCloseTo(0.009 + 0.012, 6);
});
it("includes cache tokens in input count", async () => {
const jsonl = [
'{"type":"user","message":{"content":"hi"}}',
'{"type":"assistant","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":500,"cache_creation_input_tokens":200}}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.cost?.inputTokens).toBe(800);
expect(result?.cost?.outputTokens).toBe(50);
});
it("uses model-aware pricing when cached tokens are present", async () => {
const jsonl = [
'{"type":"assistant","model":"claude-sonnet-4-5","usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":10000,"cache_creation_input_tokens":2000}}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.cost?.inputTokens).toBe(13000);
expect(result?.cost?.outputTokens).toBe(100);
expect(result?.cost?.estimatedCostUsd).toBeGreaterThan(0);
});
it("uses costUSD field when present", async () => {
const jsonl = [
'{"type":"user","message":{"content":"hi"}}',
'{"costUSD":0.05}',
'{"costUSD":0.03}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.cost?.estimatedCostUsd).toBeCloseTo(0.08);
});
it("prefers costUSD over estimatedCostUsd to avoid double-counting", async () => {
const jsonl = [
'{"type":"user","message":{"content":"hi"}}',
'{"costUSD":0.10,"estimatedCostUsd":0.10}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
// Should use costUSD only, not sum both
expect(result?.cost?.estimatedCostUsd).toBeCloseTo(0.1);
});
it("falls back to estimatedCostUsd when costUSD is absent", async () => {
const jsonl = [
'{"type":"user","message":{"content":"hi"}}',
'{"estimatedCostUsd":0.12}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.cost?.estimatedCostUsd).toBeCloseTo(0.12);
});
it("uses direct inputTokens/outputTokens fields", async () => {
const jsonl = [
'{"type":"user","message":{"content":"hi"}}',
'{"inputTokens":5000,"outputTokens":1000}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.cost?.inputTokens).toBe(5000);
expect(result?.cost?.outputTokens).toBe(1000);
});
it("returns undefined cost when no usage data", async () => {
const jsonl = '{"type":"user","message":{"content":"hi"}}';
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.cost).toBeUndefined();
});
});
describe("file selection", () => {
it("picks the most recently modified JSONL file", async () => {
mockReaddir.mockResolvedValue(["old.jsonl", "new.jsonl"]);
mockStat.mockImplementation((path: string) => {
if (path.endsWith("old.jsonl")) {
return Promise.resolve({ mtimeMs: 1000, mtime: new Date(1000) });
}
return Promise.resolve({ mtimeMs: 2000, mtime: new Date(2000) });
});
mockReadFile.mockResolvedValue('{"type":"user","message":{"content":"hi"}}');
const result = await agent.getSessionInfo(makeSession());
expect(result?.agentSessionId).toBe("new");
});
it("skips JSONL files that fail stat", async () => {
mockReaddir.mockResolvedValue(["broken.jsonl", "good.jsonl"]);
mockStat.mockImplementation((path: string) => {
if (path.endsWith("broken.jsonl")) {
return Promise.reject(new Error("ENOENT"));
}
return Promise.resolve({ mtimeMs: 1000, mtime: new Date(1000) });
});
mockReadFile.mockResolvedValue('{"type":"user","message":{"content":"hi"}}');
const result = await agent.getSessionInfo(makeSession());
expect(result?.agentSessionId).toBe("good");
});
});
describe("malformed JSONL handling", () => {
it("skips malformed lines and parses valid ones", async () => {
const jsonl = [
"not valid json",
'{"type":"summary","summary":"Good summary"}',
"{truncated",
"",
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.summary).toBe("Good summary");
});
it("skips JSON null, array, and primitive values", async () => {
const jsonl = [
"null",
"42",
'"just a string"',
"[1,2,3]",
'{"type":"summary","summary":"Valid object"}',
].join("\n");
mockJsonlFiles(jsonl);
const result = await agent.getSessionInfo(makeSession());
expect(result?.summary).toBe("Valid object");
});
it("handles readFile failure gracefully", async () => {
mockReaddir.mockResolvedValue(["session.jsonl"]);
mockStat.mockResolvedValue({ mtimeMs: 1000, mtime: new Date(1000) });
mockReadFile.mockRejectedValue(new Error("EACCES"));
const result = await agent.getSessionInfo(makeSession());
expect(result).toBeNull();
});
});
});
// =========================================================================
// METADATA_UPDATER_SCRIPT — content verification (unit tests)
// =========================================================================
describe("METADATA_UPDATER_SCRIPT content", () => {
it("contains clean_command stripping logic for cd prefixes", () => {
expect(METADATA_UPDATER_SCRIPT).toContain('clean_command="$command"');
expect(METADATA_UPDATER_SCRIPT).toMatch(/while.*clean_command.*cd/);
});
it("uses $clean_command (not $command) for all regex-based command detection", () => {
const lines = METADATA_UPDATER_SCRIPT.split("\n");
for (const line of lines) {
// Skip comment lines, the initial assignment, and the stripping logic itself
if (line.trim().startsWith("#")) continue;
if (line.includes('clean_command="$command"')) continue;
if (line.includes("while") && line.includes("clean_command")) continue;
// Any regex match line (=~) should use $clean_command, NOT $command
if (line.includes("=~") && line.includes("command")) {
expect(line).toContain("clean_command");
expect(line).not.toMatch(/"\$command"/);
}
}
});
it("does NOT use ^-anchored regexes directly on $command for gh/git detection", () => {
// The old buggy patterns matched $command with ^ anchor.
// After the fix, ^ is still used but on $clean_command (which has cd stripped).
expect(METADATA_UPDATER_SCRIPT).not.toMatch(/"\$command"\s*=~\s*\^gh/);
expect(METADATA_UPDATER_SCRIPT).not.toMatch(/"\$command"\s*=~\s*\^git/);
});
it("strips cd prefixes with both && and ; delimiters", () => {
expect(METADATA_UPDATER_SCRIPT).toMatch(/&&\|;/);
});
it("handles multiple chained cd commands via while loop", () => {
expect(METADATA_UPDATER_SCRIPT).toMatch(/while.*clean_command/);
});
it("detects gh pr create on clean_command", () => {
expect(METADATA_UPDATER_SCRIPT).toMatch(/"\$clean_command"\s*=~\s*\^gh\[/);
});
it("detects git checkout -b on clean_command", () => {
expect(METADATA_UPDATER_SCRIPT).toMatch(/"\$clean_command"\s*=~\s*\^git\[.*checkout/);
});
it("detects gh pr merge on clean_command", () => {
expect(METADATA_UPDATER_SCRIPT).toMatch(/"\$clean_command"\s*=~\s*\^gh\[.*merge/);
});
});
// =========================================================================
// setupWorkspaceHooks / postLaunchSetup — hook path (symlink safety)
// =========================================================================
describe("hook setup — relative path (symlink-safe)", () => {
const agent = create();
/** Extract the hook command from the settings.json that was written */
function getWrittenHookCommand(): string {
const settingsWrite = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
expect(settingsWrite).toBeDefined();
const parsed = JSON.parse(settingsWrite![1] as string);
return parsed.hooks.PostToolUse[0].hooks[0].command;
}
it("setupWorkspaceHooks writes a relative hook command (not absolute)", async () => {
await agent.setupWorkspaceHooks!(
"/Users/equinox/.worktrees/integrator/integrator-5",
{} as WorkspaceHooksConfig,
);
const hookCommand = getWrittenHookCommand();
expect(hookCommand).toBe(".claude/metadata-updater.sh");
expect(hookCommand).not.toMatch(/^\//);
});
it("postLaunchSetup is a no-op (hooks installed pre-launch via setupWorkspaceHooks)", async () => {
mockWriteFile.mockClear();
await agent.postLaunchSetup!(
makeSession({ workspacePath: "/Users/equinox/.worktrees/integrator/integrator-10" }),
);
// No files should be written — hooks are installed before launch
const settingsWrites = mockWriteFile.mock.calls.filter(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
expect(settingsWrites).toHaveLength(0);
});
it("different worktree paths produce identical settings.json content", async () => {
await agent.setupWorkspaceHooks!(
"/Users/equinox/.worktrees/integrator/integrator-5",
{} as WorkspaceHooksConfig,
);
const settingsWrite1 = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
const content1 = settingsWrite1![1] as string;
mockWriteFile.mockClear();
await agent.setupWorkspaceHooks!(
"/Users/equinox/.worktrees/integrator/integrator-10",
{} as WorkspaceHooksConfig,
);
const settingsWrite2 = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
const content2 = settingsWrite2![1] as string;
expect(content1).toBe(content2);
});
it("updates an existing absolute hook path to relative", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFile.mockResolvedValue(
JSON.stringify({
hooks: {
PostToolUse: [
{
matcher: "Bash",
hooks: [
{
type: "command",
command:
"/Users/equinox/.worktrees/integrator/integrator-5/.claude/metadata-updater.sh",
timeout: 5000,
},
],
},
],
},
}),
);
await agent.setupWorkspaceHooks!(
"/Users/equinox/.worktrees/integrator/integrator-10",
{} as WorkspaceHooksConfig,
);
const hookCommand = getWrittenHookCommand();
expect(hookCommand).toBe(".claude/metadata-updater.sh");
});
it("still writes the script file to the correct absolute filesystem path", async () => {
await agent.setupWorkspaceHooks!(
"/Users/equinox/.worktrees/integrator/integrator-5",
{} as WorkspaceHooksConfig,
);
const scriptWrite = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("metadata-updater.sh"),
);
expect(scriptWrite).toBeDefined();
expect(scriptWrite![0]).toBe(
pathJoin(
"/Users/equinox/.worktrees/integrator/integrator-5",
".claude",
"metadata-updater.sh",
),
);
});
it("skips postLaunchSetup when workspacePath is null", async () => {
await agent.postLaunchSetup!(makeSession({ workspacePath: null }));
expect(mockWriteFile).not.toHaveBeenCalled();
});
});
// =========================================================================
// setupWorkspaceHooks on win32 — Node.js hook script
// =========================================================================
describe("setupWorkspaceHooks on win32", () => {
const agent = create();
/** Extract the hook command written to settings.json */
function getWrittenHookCommand(): string {
const settingsWrite = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
expect(settingsWrite).toBeDefined();
const parsed = JSON.parse(settingsWrite![1] as string);
return parsed.hooks.PostToolUse[0].hooks[0].command;
}
/** Get the content written to the hook script file */
function getWrittenScriptContent(ext: string): string | undefined {
const scriptWrite = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith(ext),
);
return scriptWrite ? (scriptWrite[1] as string) : undefined;
}
beforeEach(() => {
mockIsWindows.mockReturnValue(true);
});
afterEach(() => {
mockIsWindows.mockReturnValue(false);
});
it("writes a Node.js hook script instead of bash on Windows", async () => {
await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig);
// The .cjs file must have been written (.cjs forces CJS mode in ESM workspaces)
const cjsContent = getWrittenScriptContent("metadata-updater.cjs");
expect(cjsContent).toBeDefined();
expect(cjsContent).toContain("#!/usr/bin/env node");
// Must not contain bash-isms
expect(cjsContent).not.toContain("#!/usr/bin/env bash");
expect(cjsContent).not.toContain("jq");
expect(cjsContent).not.toContain("grep");
expect(cjsContent).not.toContain("sed");
// The .sh and .js files must NOT have been written
const shContent = getWrittenScriptContent("metadata-updater.sh");
expect(shContent).toBeUndefined();
const jsContent = getWrittenScriptContent("metadata-updater.js");
expect(jsContent).toBeUndefined();
});
it("uses node command in settings.json hook command on Windows", async () => {
await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig);
const hookCommand = getWrittenHookCommand();
expect(hookCommand).toBe("node .claude/metadata-updater.cjs");
expect(hookCommand).not.toContain(".sh");
});
it("skips chmod on win32", async () => {
await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig);
expect(mockChmod).not.toHaveBeenCalled();
});
it("exports METADATA_UPDATER_SCRIPT_NODE with Node.js shebang", () => {
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("#!/usr/bin/env node");
expect(METADATA_UPDATER_SCRIPT_NODE).not.toContain("jq");
expect(METADATA_UPDATER_SCRIPT_NODE).not.toContain("grep");
expect(METADATA_UPDATER_SCRIPT_NODE).not.toContain("sed");
});
it("Node.js hook script handles gh pr create detection", () => {
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("gh");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("pr");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("create");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("updateMetadataKey");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("pr_open");
});
it("Node.js hook script handles git checkout -b detection", () => {
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("checkout");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("-b");
});
it("Node.js hook script handles gh pr merge detection", () => {
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("pr\\s+merge");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("merged");
});
it("Node.js hook script validates AO_DATA_DIR against allowed directories", () => {
// Must contain the allowlist check mirroring ao-metadata-helper.sh and
// the Node.js wrappers in agent-workspace-hooks.ts (C-1 security fix)
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("allowedBases");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("realpathSync");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain(".ao");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain(".agent-orchestrator");
expect(METADATA_UPDATER_SCRIPT_NODE).toContain("os.tmpdir");
});
it("does not add duplicate hook entry when called twice on Windows", async () => {
// First call creates the hook
await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig);
// Simulate second call: settings.json now contains the .cjs hook
const firstSettings = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
expect(firstSettings).toBeDefined();
mockReadFile.mockResolvedValueOnce(firstSettings![1] as string);
vi.clearAllMocks();
mockIsWindows.mockReturnValue(true);
// Second call — should UPDATE the existing hook, not add a duplicate
await agent.setupWorkspaceHooks!("C:\\\\Users\\\\dev\\\\workspace", {} as WorkspaceHooksConfig);
const secondSettings = mockWriteFile.mock.calls.find(
([path]: unknown[]) => typeof path === "string" && path.endsWith("settings.json"),
);
expect(secondSettings).toBeDefined();
const parsed = JSON.parse(secondSettings![1] as string);
const hookEntries = parsed.hooks.PostToolUse as Array<{ hooks: Array<{ command: string }> }>;
// Count all hook commands matching our metadata updater
const metadataHooks = hookEntries
.flatMap((e) => e.hooks)
.filter((h) => h.command.includes("metadata-updater"));
// Must be exactly 1 — no duplicates
expect(metadataHooks).toHaveLength(1);
});
});