import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-core"; // --------------------------------------------------------------------------- // Hoisted mocks — available inside vi.mock factories // --------------------------------------------------------------------------- const { mockExecFileAsync, mockReaddir, mockReadFile, mockStat, mockHomedir } = vi.hoisted(() => ({ mockExecFileAsync: vi.fn(), mockReaddir: vi.fn(), mockReadFile: vi.fn(), mockStat: vi.fn(), mockHomedir: vi.fn(() => "/mock/home"), })); 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, })); vi.mock("node:os", () => ({ homedir: mockHomedir, })); import { create, manifest, default as defaultExport } from "./index.js"; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- function makeSession(overrides: Partial = {}): Session { return { id: "test-1", projectId: "test-project", status: "working", activity: "active", 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 { 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(); mockHomedir.mockReturnValue("/mock/home"); }); 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", }); }); 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()); 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=skip", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" })); 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("shell-escapes prompt argument", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix the bug" })); expect(cmd).toContain("-p 'Fix the bug'"); }); it("escapes dangerous characters in prompt", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "$(rm -rf /); `evil`; $HOME" })); // Single-quoted strings prevent shell expansion expect(cmd).toContain("-p '$(rm -rf /); `evil`; $HOME'"); }); it("escapes single quotes in prompt using POSIX method", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's a test" })); expect(cmd).toContain("-p 'it'\\''s a test'"); }); it("combines all options", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ permissions: "skip", model: "opus", prompt: "Hello" }), ); expect(cmd).toBe("claude --dangerously-skip-permissions --model 'opus' -p 'Hello'"); }); it("omits optional flags when not provided", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig()); expect(cmd).not.toContain("--dangerously-skip-permissions"); expect(cmd).not.toContain("--model"); expect(cmd).not.toContain("-p"); }); }); // ========================================================================= // 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); }); 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 false when tmux command fails", async () => { mockExecFileAsync.mockRejectedValue(new Error("fail")); expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); }); 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("does not match similar process names like claude-code", async () => { mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => { if (cmd === "tmux" && args[0] === "list-panes") { return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" }); } if (cmd === "ps") { return Promise.resolve({ stdout: " PID TT ARGS\n 100 ttys001 /usr/bin/claude-code\n", stderr: "", }); } return Promise.reject(new Error("unexpected")); }); expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(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("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 active for non-empty output with no special patterns", () => { expect(agent.detectActivity("some random terminal output\n")).toBe("active"); }); }); // ========================================================================= // 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( "/mock/home/.claude/projects/-Users-dev--worktrees-ao-ao-3", ); }); }); 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"); }); }); 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 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(); }); }); });