import { describe, it, expect, vi, beforeEach } from "vitest"; import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core"; // Mock fs/promises for getSessionInfo tests vi.mock("node:fs/promises", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, readFile: vi.fn().mockRejectedValue(new Error("ENOENT")), access: vi.fn().mockRejectedValue(new Error("ENOENT")), stat: vi.fn().mockRejectedValue(new Error("ENOENT")), lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }), }; }); // Mock fs (sync) for getLaunchCommand systemPromptFile symlink checks const { mockLstatSync } = vi.hoisted(() => ({ mockLstatSync: vi.fn().mockReturnValue({ isSymbolicLink: () => false }), })); vi.mock("node:fs", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, lstatSync: mockLstatSync, }; }); // Mock activity log utilities from core const { mockAppendActivityEntry, mockReadLastActivityEntry, mockRecordTerminalActivity } = vi.hoisted(() => ({ mockAppendActivityEntry: vi.fn().mockResolvedValue(undefined), mockReadLastActivityEntry: vi.fn().mockResolvedValue(null), mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined), })); vi.mock("@aoagents/ao-core", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, appendActivityEntry: mockAppendActivityEntry, readLastActivityEntry: mockReadLastActivityEntry, recordTerminalActivity: mockRecordTerminalActivity, }; }); // --------------------------------------------------------------------------- // Hoisted mocks // --------------------------------------------------------------------------- const { mockExecFileAsync, mockExecFileSync } = vi.hoisted(() => ({ mockExecFileAsync: vi.fn(), mockExecFileSync: vi.fn(), })); vi.mock("node:child_process", () => { const fn = Object.assign((..._args: unknown[]) => {}, { [Symbol.for("nodejs.util.promisify.custom")]: mockExecFileAsync, }); return { execFile: fn, execFileSync: mockExecFileSync, }; }); import { create, manifest, default as defaultExport, detect } from "./index.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeSession(overrides: Partial = {}): 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", 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: string, found = true) { mockExecFileAsync.mockImplementation((cmd: string) => { if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys005\n", stderr: "" }); if (cmd === "ps") { const line = found ? ` 444 ttys005 ${processName}` : " 444 ttys005 zsh"; return Promise.resolve({ stdout: ` PID TT ARGS\n${line}\n`, stderr: "", }); } return Promise.reject(new Error("unexpected")); }); } beforeEach(() => { vi.clearAllMocks(); }); // ========================================================================= // Manifest & Exports // ========================================================================= describe("plugin manifest & exports", () => { it("has correct manifest", () => { expect(manifest).toEqual({ name: "cursor", slot: "agent", description: "Agent plugin: Cursor Agent CLI", version: "0.1.0", displayName: "Cursor", }); }); it("create() returns agent with correct name and processName", () => { const agent = create(); expect(agent.name).toBe("cursor"); expect(agent.processName).toBe("agent"); }); 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", () => { expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("agent"); }); it("includes --force --sandbox disabled --approve-mcps when permissions=permissionless", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "permissionless" })); expect(cmd).toContain("--force"); expect(cmd).toContain("--sandbox disabled"); expect(cmd).toContain("--approve-mcps"); }); it("treats legacy permissions=skip as permissionless", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ permissions: "skip" as unknown as AgentLaunchConfig["permissions"] }), ); expect(cmd).toContain("--force"); }); it("maps permissions=auto-edit to force mode on Cursor", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "auto-edit" })); expect(cmd).toContain("--force"); }); it("includes --model with shell-escaped value", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "gpt-4o" })); expect(cmd).toContain("--model 'gpt-4o'"); }); it("includes prompt as positional argument (not --prompt flag)", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix the tests" })); expect(cmd).toContain("'Fix the tests'"); expect(cmd).not.toContain("--prompt"); }); it("combines all options", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ permissions: "permissionless", model: "sonnet", prompt: "Go" }), ); expect(cmd).toBe("agent --force --sandbox disabled --approve-mcps --model 'sonnet' -- 'Go'"); }); it("escapes single quotes in prompt", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's broken" })); // shellEscape picks POSIX ('\'') on Unix, PowerShell ('') on Windows. const expected = process.platform === "win32" ? "'it''s broken'" : "'it'\\''s broken'"; expect(cmd).toContain(expected); }); it("omits optional flags when not provided", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig()); expect(cmd).not.toContain("--force"); expect(cmd).not.toContain("--model"); }); it("uses shell substitution for systemPromptFile with prompt (printf %s for safety)", () => { // Uses $(cat ...; printf; printf %s ...) pattern to avoid shell injection // This matches OpenCode's pattern exactly - prompt is shellEscaped (single quotes) // and wrapped in printf %s to prevent shell expansion const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPromptFile: "/path/to/system.txt", prompt: "Do the task" }), ); expect(cmd).toContain("$(cat '/path/to/system.txt'"); // Must use printf %s with shellEscaped prompt for security expect(cmd).toContain("printf %s 'Do the task'"); expect(cmd).toContain("printf '\\n\\n'"); // Should use double quotes to allow shell expansion expect(cmd).toMatch(/--\s+".*\$\(cat/); }); it("prepends inline systemPrompt to prompt when systemPromptFile not provided", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPrompt: "You are an expert.", prompt: "Do the task" }), ); expect(cmd).toContain("You are an expert."); expect(cmd).toContain("Do the task"); }); it("prefers systemPromptFile over systemPrompt (shell substitution)", () => { const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPromptFile: "/path/to/file.txt", systemPrompt: "Inline prompt", prompt: "Task", }), ); // Should use file via $(cat) with printf %s for prompt, not inline prompt expect(cmd).toContain("$(cat '/path/to/file.txt'"); expect(cmd).toContain("printf %s 'Task'"); expect(cmd).not.toContain("Inline prompt"); }); it("uses shell substitution for systemPromptFile without prompt", () => { const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "/path/sys.txt" })); // Without prompt, just $(cat) is used (no printf %s needed) expect(cmd).toContain("$(cat '/path/sys.txt')"); expect(cmd).not.toContain("printf %s"); expect(cmd).toMatch(/--\s+".*\$\(cat/); }); it("falls back to inline handling if lstat fails", () => { mockLstatSync.mockImplementationOnce(() => { throw new Error("ENOENT"); }); const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPromptFile: "/nonexistent.txt", prompt: "Do the task" }), ); // Falls back to just the prompt when file doesn't exist expect(cmd).toBe("agent -- 'Do the task'"); }); it("rejects symlinked systemPromptFile for security", () => { mockLstatSync.mockReturnValueOnce({ isSymbolicLink: () => true }); const cmd = agent.getLaunchCommand( makeLaunchConfig({ systemPromptFile: "/path/to/symlink.txt", prompt: "Do the task" }), ); // Should skip the symlinked file and only include the prompt expect(cmd).toBe("agent -- 'Do the task'"); // Should not use $(cat) for symlinked file expect(cmd).not.toContain("$(cat"); }); }); // ========================================================================= // getEnvironment // ========================================================================= describe("getEnvironment", () => { const agent = create(); 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: "LIN-99" })); expect(env["AO_ISSUE_ID"]).toBe("LIN-99"); }); it("omits 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 agent found on tmux pane TTY", async () => { mockTmuxWithProcess("agent"); expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true); }); it("returns true when /path/to/agent found on tmux pane TTY", async () => { mockTmuxWithProcess("/usr/local/bin/agent"); expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true); }); it("returns false when agent not on tmux pane TTY", async () => { mockTmuxWithProcess("agent", false); expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); }); it("returns true for process handle with alive PID", async () => { const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); expect(await agent.isProcessRunning(makeProcessHandle(456))).toBe(true); expect(killSpy).toHaveBeenCalledWith(456, 0); killSpy.mockRestore(); }); it("returns false for process handle with dead PID", async () => { const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { throw new Error("ESRCH"); }); expect(await agent.isProcessRunning(makeProcessHandle(456))).toBe(false); killSpy.mockRestore(); }); it("returns false for unknown runtime without PID", async () => { const handle: RuntimeHandle = { id: "x", runtimeName: "other", data: {} }; expect(await agent.isProcessRunning(handle)).toBe(false); }); it("returns false on tmux command failure", async () => { mockExecFileAsync.mockRejectedValue(new Error("tmux gone")); 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 agent on any pane in multi-pane session", async () => { mockExecFileAsync.mockImplementation((cmd: string) => { if (cmd === "tmux") { 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 agent --force\n", stderr: "", }); } return Promise.reject(new Error("unexpected")); }); expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true); }); }); // ========================================================================= // 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 ")).toBe("idle"); }); it("returns idle when prompt char visible", () => { expect(agent.detectActivity("some output\n> ")).toBe("idle"); expect(agent.detectActivity("some output\n$ ")).toBe("idle"); }); it("returns idle for agent-specific prompts", () => { expect(agent.detectActivity("Processing...\nagent> ")).toBe("idle"); expect(agent.detectActivity("Ready.\n[agent] ")).toBe("idle"); }); it("returns waiting_input for Y/N confirmation", () => { expect(agent.detectActivity("Approve these changes?\n(Y)es/(N)o")).toBe("waiting_input"); }); it("returns waiting_input for continue prompt", () => { expect(agent.detectActivity("Ready to proceed. Continue?")).toBe("waiting_input"); }); it("returns waiting_input for press enter prompt", () => { expect(agent.detectActivity("Press Enter to continue")).toBe("waiting_input"); }); it("returns active for non-empty terminal output", () => { expect(agent.detectActivity("agent is processing files\n")).toBe("active"); }); }); // ========================================================================= // getSessionInfo // ========================================================================= 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 no cursor session file exists", async () => { const { readFile } = await import("node:fs/promises"); vi.mocked(readFile).mockRejectedValueOnce(new Error("ENOENT")); expect(await agent.getSessionInfo(makeSession())).toBeNull(); }); it("extracts summary from cursor chat file", async () => { const { access, readFile } = await import("node:fs/promises"); vi.mocked(access).mockResolvedValueOnce(undefined); vi.mocked(readFile).mockResolvedValueOnce("# Cursor Session\n\nFix the login bug in auth.ts\n"); const info = await agent.getSessionInfo(makeSession()); expect(info).not.toBeNull(); expect(info!.summary).toBe("Fix the login bug in auth.ts"); expect(info!.summaryIsFallback).toBe(true); expect(info!.agentSessionId).toBeNull(); expect(info!.cost).toBeUndefined(); }); it("truncates long summaries to 120 chars", async () => { const { access, readFile } = await import("node:fs/promises"); const longMsg = "A".repeat(200); vi.mocked(access).mockResolvedValueOnce(undefined); vi.mocked(readFile).mockResolvedValueOnce(longMsg); const info = await agent.getSessionInfo(makeSession()); expect(info!.summary).toHaveLength(123); // 120 + "..." expect(info!.summary!.endsWith("...")).toBe(true); }); }); // ========================================================================= // getRestoreCommand // ========================================================================= describe("getRestoreCommand", () => { const agent = create(); it("returns null (cursor does not support session resume)", async () => { const result = await agent.getRestoreCommand!( makeSession(), { name: "proj", repo: "o/r", path: "/p", defaultBranch: "main", sessionPrefix: "p" }, ); expect(result).toBeNull(); }); }); // ========================================================================= // setupWorkspaceHooks // ========================================================================= describe("setupWorkspaceHooks", () => { const agent = create(); it("is defined (delegates to shared setupPathWrapperWorkspace)", () => { expect(agent.setupWorkspaceHooks).toBeDefined(); expect(typeof agent.setupWorkspaceHooks).toBe("function"); }); }); // ========================================================================= // postLaunchSetup // ========================================================================= describe("postLaunchSetup", () => { const agent = create(); it("is defined", () => { expect(agent.postLaunchSetup).toBeDefined(); expect(typeof agent.postLaunchSetup).toBe("function"); }); it("does nothing when workspacePath is null", async () => { // Should not throw await agent.postLaunchSetup!(makeSession({ workspacePath: null })); }); }); // ========================================================================= // getEnvironment — PATH wrapping // ========================================================================= describe("getEnvironment PATH", () => { const agent = create(); it("does not set PATH (injected by session-manager)", () => { const env = agent.getEnvironment(makeLaunchConfig()); expect(env["PATH"]).toBeUndefined(); }); it("does not set GH_PATH (injected by session-manager)", () => { const env = agent.getEnvironment(makeLaunchConfig()); expect(env["GH_PATH"]).toBeUndefined(); }); }); // ========================================================================= // recordActivity // ========================================================================= describe("recordActivity", () => { const agent = create(); it("is defined", () => { expect(agent.recordActivity).toBeDefined(); }); it("does nothing when workspacePath is null", async () => { await agent.recordActivity!(makeSession({ workspacePath: null }), "some output"); expect(mockRecordTerminalActivity).not.toHaveBeenCalled(); }); it("delegates to recordTerminalActivity", async () => { await agent.recordActivity!(makeSession(), "agent is processing files"); expect(mockRecordTerminalActivity).toHaveBeenCalledWith( "/workspace/test", "agent is processing files", expect.any(Function), ); }); }); // ========================================================================= // getActivityState — reads from activity JSONL // ========================================================================= describe("getActivityState with activity JSONL", () => { const agent = create(); it("returns exited when process is not running", async () => { mockTmuxWithProcess("agent", false); const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("exited"); }); it("returns waiting_input from activity JSONL", async () => { mockTmuxWithProcess("agent"); mockReadLastActivityEntry.mockResolvedValueOnce({ entry: { ts: new Date().toISOString(), state: "waiting_input", source: "terminal" }, modifiedAt: new Date(), }); const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("waiting_input"); }); it("returns blocked from activity JSONL", async () => { mockTmuxWithProcess("agent"); mockReadLastActivityEntry.mockResolvedValueOnce({ entry: { ts: new Date().toISOString(), state: "blocked", source: "terminal" }, modifiedAt: new Date(), }); const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("blocked"); }); it("returns active from JSONL entry fallback when native signal fails (fresh entry)", async () => { mockTmuxWithProcess("agent"); mockReadLastActivityEntry.mockResolvedValueOnce({ entry: { ts: new Date().toISOString(), state: "active", source: "terminal" }, modifiedAt: new Date(), }); const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("active"); }); it("returns idle from JSONL entry fallback when native signal fails (old entry with age decay)", async () => { mockTmuxWithProcess("agent"); const oldDate = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago mockReadLastActivityEntry.mockResolvedValueOnce({ entry: { ts: oldDate.toISOString(), state: "active", source: "terminal" }, modifiedAt: oldDate, }); const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result?.state).toBe("idle"); }); it("returns null when both native signal and JSONL are unavailable", async () => { mockTmuxWithProcess("agent"); mockReadLastActivityEntry.mockResolvedValueOnce(null); const result = await agent.getActivityState(makeSession({ runtimeHandle: makeTmuxHandle() })); expect(result).toBeNull(); }); }); describe("detect()", () => { beforeEach(() => { vi.clearAllMocks(); }); it("returns true when agent --help contains 'Cursor Agent'", () => { mockExecFileSync.mockReturnValueOnce("Usage: agent [options]\n\nStart the Cursor Agent\n"); expect(detect()).toBe(true); expect(mockExecFileSync).toHaveBeenCalledWith( "agent", ["--help"], expect.objectContaining({ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }), ); }); it("returns true when agent --help contains Cursor-specific flags (fallback)", () => { // Even without "Cursor Agent" text, detect via unique flag combination mockExecFileSync.mockReturnValueOnce( "Usage: agent\n--sandbox enabled|disabled\n--approve-mcps\n", ); expect(detect()).toBe(true); }); it("returns false when agent binary is not found", () => { mockExecFileSync.mockImplementationOnce(() => { throw new Error("ENOENT"); }); expect(detect()).toBe(false); }); it("returns false when agent --help has neither Cursor markers nor flags", () => { mockExecFileSync.mockReturnValueOnce("Some other agent\nUsage: agent [options]\n"); expect(detect()).toBe(false); }); it("returns false when only one Cursor flag is present (not enough)", () => { // Need BOTH --sandbox AND --approve-mcps to match via flags mockExecFileSync.mockReturnValueOnce("Usage: agent\n--sandbox enabled|disabled\n"); expect(detect()).toBe(false); }); it("returns false when execFileSync throws an error", () => { mockExecFileSync.mockImplementationOnce(() => { throw new Error("Command failed"); }); expect(detect()).toBe(false); }); });