agent-orchestrator/packages/plugins/agent-codex/src/index.test.ts

294 lines
9.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-core";
// ---------------------------------------------------------------------------
// Hoisted mocks
// ---------------------------------------------------------------------------
const { mockExecFileAsync } = vi.hoisted(() => ({
mockExecFileAsync: vi.fn(),
}));
vi.mock("node:child_process", () => {
const fn = Object.assign((..._args: unknown[]) => {}, {
[Symbol.for("nodejs.util.promisify.custom")]: mockExecFileAsync,
});
return { execFile: fn };
});
import { create, manifest, default as defaultExport } from "./index.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: "test-1",
projectId: "test-project",
status: "working",
activity: "active",
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 | string): 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: string, found = true) {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
if (cmd === "ps") {
const line = found ? ` 789 ttys003 ${processName}` : " 789 ttys003 bash";
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: "codex",
slot: "agent",
description: "Agent plugin: OpenAI Codex CLI",
version: "0.1.0",
});
});
it("create() returns agent with correct name and processName", () => {
const agent = create();
expect(agent.name).toBe("codex");
expect(agent.processName).toBe("codex");
});
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("codex");
});
it("includes --approval-mode full-auto when permissions=skip", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" }));
expect(cmd).toContain("--approval-mode full-auto");
});
it("includes --model with shell-escaped value", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "gpt-4o" }));
expect(cmd).toContain("--model 'gpt-4o'");
});
it("appends shell-escaped prompt with -- separator", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Fix it" }));
expect(cmd).toContain("-- 'Fix it'");
});
it("combines all options", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "skip", model: "o3", prompt: "Go" }),
);
expect(cmd).toBe("codex --approval-mode full-auto --model 'o3' -- 'Go'");
});
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's broken" }));
expect(cmd).toContain("-- 'it'\\''s broken'");
});
it("omits optional flags when not provided", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig());
expect(cmd).not.toContain("--approval-mode");
expect(cmd).not.toContain("--model");
});
});
// =========================================================================
// getEnvironment
// =========================================================================
describe("getEnvironment", () => {
const agent = create();
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_SESSION_ID"]).toBe("sess-1");
expect(env["AO_PROJECT_ID"]).toBe("my-project");
});
it("sets AO_ISSUE_ID when provided", () => {
const env = agent.getEnvironment(makeLaunchConfig({ issueId: "GH-42" }));
expect(env["AO_ISSUE_ID"]).toBe("GH-42");
});
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 codex found on tmux pane TTY", async () => {
mockTmuxWithProcess("codex");
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
});
it("returns false when codex not on tmux pane TTY", async () => {
mockTmuxWithProcess("codex", 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(123))).toBe(true);
expect(killSpy).toHaveBeenCalledWith(123, 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(123))).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 not running"));
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 codex 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 codex --model o3\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 active for non-empty terminal output", () => {
expect(agent.detectActivity("codex is running some task\n")).toBe("active");
});
});
// =========================================================================
// isProcessing
// =========================================================================
describe("isProcessing", () => {
const agent = create();
it("returns false when no runtime handle", async () => {
expect(await agent.isProcessing(makeSession())).toBe(false);
});
it("returns true when process is running", async () => {
mockTmuxWithProcess("codex");
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
expect(await agent.isProcessing(session)).toBe(true);
});
it("returns false when process is not running", async () => {
mockTmuxWithProcess("codex", false);
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
expect(await agent.isProcessing(session)).toBe(false);
});
});
// =========================================================================
// getSessionInfo
// =========================================================================
describe("getSessionInfo", () => {
const agent = create();
it("always returns null (not implemented)", async () => {
expect(await agent.getSessionInfo(makeSession())).toBeNull();
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/some/path" }))).toBeNull();
});
});