Merge pull request #154 from suraj-markup/suraj/codex-maturity
feat(agent-codex): mature Codex plugin to match Claude Code support
This commit is contained in:
commit
ae508c90ee
|
|
@ -234,6 +234,62 @@ describe("spawn command", () => {
|
|||
expect(output).toContain("8474d6f29887-app-7");
|
||||
});
|
||||
|
||||
it("passes --agent flag to sessionManager.spawn()", async () => {
|
||||
const fakeSession: Session = {
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
mockSessionManager.spawn.mockResolvedValue(fakeSession);
|
||||
|
||||
await program.parseAsync(["node", "test", "spawn", "my-app", "--agent", "codex"]);
|
||||
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
|
||||
projectId: "my-app",
|
||||
issueId: undefined,
|
||||
agent: "codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes --agent flag with issue ID", async () => {
|
||||
const fakeSession: Session = {
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
branch: "feat/INT-42",
|
||||
issueId: "INT-42",
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
mockSessionManager.spawn.mockResolvedValue(fakeSession);
|
||||
|
||||
await program.parseAsync(["node", "test", "spawn", "my-app", "INT-42", "--agent", "codex"]);
|
||||
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
|
||||
projectId: "my-app",
|
||||
issueId: "INT-42",
|
||||
agent: "codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown project ID", async () => {
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "spawn", "nonexistent"]),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ async function spawnSession(
|
|||
projectId: string,
|
||||
issueId?: string,
|
||||
openTab?: boolean,
|
||||
agent?: string,
|
||||
): Promise<string> {
|
||||
const spinner = ora("Creating session").start();
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ async function spawnSession(
|
|||
const session = await sm.spawn({
|
||||
projectId,
|
||||
issueId,
|
||||
agent,
|
||||
});
|
||||
|
||||
spinner.succeed(`Session ${chalk.green(session.id)} created`);
|
||||
|
|
@ -58,7 +60,8 @@ export function registerSpawn(program: Command): void {
|
|||
.argument("<project>", "Project ID from config")
|
||||
.argument("[issue]", "Issue identifier (e.g. INT-1234, #42) - must exist in tracker")
|
||||
.option("--open", "Open session in terminal tab")
|
||||
.action(async (projectId: string, issueId: string | undefined, opts: { open?: boolean }) => {
|
||||
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
|
||||
.action(async (projectId: string, issueId: string | undefined, opts: { open?: boolean; agent?: string }) => {
|
||||
const config = loadConfig();
|
||||
if (!config.projects[projectId]) {
|
||||
console.error(
|
||||
|
|
@ -70,7 +73,7 @@ export function registerSpawn(program: Command): void {
|
|||
}
|
||||
|
||||
try {
|
||||
await spawnSession(config, projectId, issueId, opts.open);
|
||||
await spawnSession(config, projectId, issueId, opts.open, opts.agent);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ ${err}`));
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -140,6 +140,26 @@ describe("loadBuiltins", () => {
|
|||
// In the test environment, most are not resolvable — should not throw.
|
||||
await expect(registry.loadBuiltins()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers multiple agent plugins from importFn", async () => {
|
||||
const registry = createPluginRegistry();
|
||||
|
||||
const fakeClaudeCode = makePlugin("agent", "claude-code");
|
||||
const fakeCodex = makePlugin("agent", "codex");
|
||||
|
||||
await registry.loadBuiltins(undefined, async (pkg: string) => {
|
||||
if (pkg === "@composio/ao-plugin-agent-claude-code") return fakeClaudeCode;
|
||||
if (pkg === "@composio/ao-plugin-agent-codex") return fakeCodex;
|
||||
throw new Error(`Not found: ${pkg}`);
|
||||
});
|
||||
|
||||
const agents = registry.list("agent");
|
||||
expect(agents).toContainEqual(expect.objectContaining({ name: "claude-code", slot: "agent" }));
|
||||
expect(agents).toContainEqual(expect.objectContaining({ name: "codex", slot: "agent" }));
|
||||
|
||||
expect(registry.get("agent", "codex")).not.toBeNull();
|
||||
expect(registry.get("agent", "claude-code")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractPluginConfig (via register with config)", () => {
|
||||
|
|
|
|||
|
|
@ -227,6 +227,94 @@ describe("spawn", () => {
|
|||
await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
describe("agent override", () => {
|
||||
let mockCodexAgent: Agent;
|
||||
let registryWithMultipleAgents: PluginRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCodexAgent = {
|
||||
name: "codex",
|
||||
processName: "codex",
|
||||
getLaunchCommand: vi.fn().mockReturnValue("codex --start"),
|
||||
getEnvironment: vi.fn().mockReturnValue({ CODEX_VAR: "1" }),
|
||||
detectActivity: vi.fn().mockReturnValue("active"),
|
||||
getActivityState: vi.fn().mockResolvedValue(null),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
getSessionInfo: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
registryWithMultipleAgents = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") {
|
||||
if (name === "mock-agent") return mockAgent;
|
||||
if (name === "codex") return mockCodexAgent;
|
||||
return null;
|
||||
}
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
it("uses overridden agent when spawnConfig.agent is provided", async () => {
|
||||
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
|
||||
|
||||
await sm.spawn({ projectId: "my-app", agent: "codex" });
|
||||
|
||||
expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled();
|
||||
expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when agent override plugin is not found", async () => {
|
||||
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
|
||||
|
||||
await expect(
|
||||
sm.spawn({ projectId: "my-app", agent: "nonexistent" }),
|
||||
).rejects.toThrow("Agent plugin 'nonexistent' not found");
|
||||
});
|
||||
|
||||
it("uses default agent when no override specified", async () => {
|
||||
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
|
||||
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
expect(mockAgent.getLaunchCommand).toHaveBeenCalled();
|
||||
expect(mockCodexAgent.getLaunchCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists agent name in metadata when override is used", async () => {
|
||||
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
|
||||
|
||||
await sm.spawn({ projectId: "my-app", agent: "codex" });
|
||||
|
||||
const meta = readMetadataRaw(sessionsDir, "app-1");
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!["agent"]).toBe("codex");
|
||||
});
|
||||
|
||||
it("persists default agent name in metadata when no override", async () => {
|
||||
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
|
||||
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
const meta = readMetadataRaw(sessionsDir, "app-1");
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!["agent"]).toBe("mock-agent");
|
||||
});
|
||||
|
||||
it("readMetadata returns agent field (typed SessionMetadata)", async () => {
|
||||
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
|
||||
|
||||
await sm.spawn({ projectId: "my-app", agent: "codex" });
|
||||
|
||||
const meta = readMetadata(sessionsDir, "app-1");
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.agent).toBe("codex");
|
||||
});
|
||||
});
|
||||
|
||||
it("validates issue exists when issueId provided", async () => {
|
||||
const mockTracker: Tracker = {
|
||||
name: "mock-tracker",
|
||||
|
|
|
|||
|
|
@ -183,7 +183,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
const project = config.projects[session.projectId];
|
||||
if (!project) return session.status;
|
||||
|
||||
const agent = registry.get<Agent>("agent", project.agent ?? config.defaults.agent);
|
||||
const agentName = session.metadata["agent"] ?? project.agent ?? config.defaults.agent;
|
||||
const agent = registry.get<Agent>("agent", agentName);
|
||||
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;
|
||||
|
||||
// 1. Check if runtime is alive
|
||||
|
|
@ -229,7 +230,26 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
}
|
||||
}
|
||||
|
||||
// 3. Check PR state if PR exists
|
||||
// 3. Auto-detect PR by branch if metadata.pr is missing.
|
||||
// This is critical for agents without auto-hook systems (Codex, Aider,
|
||||
// OpenCode) that can't reliably write pr=<url> to metadata on their own.
|
||||
if (!session.pr && scm && session.branch) {
|
||||
try {
|
||||
const detectedPR = await scm.detectPR(session, project);
|
||||
if (detectedPR) {
|
||||
session.pr = detectedPR;
|
||||
// Persist PR URL so subsequent polls don't need to re-query.
|
||||
// Don't write status here — step 4 below will determine the
|
||||
// correct status (merged, ci_failed, etc.) on this same cycle.
|
||||
const sessionsDir = getSessionsDir(config.configPath, project.path);
|
||||
updateMetadata(sessionsDir, session.id, { pr: detectedPR.url });
|
||||
}
|
||||
} catch {
|
||||
// SCM detection failed — will retry next poll
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check PR state if PR exists
|
||||
if (session.pr && scm) {
|
||||
try {
|
||||
const prState = await scm.getPRState(session.pr);
|
||||
|
|
@ -257,7 +277,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
|
|||
}
|
||||
}
|
||||
|
||||
// 4. Default: if agent is active, it's working
|
||||
// 5. Default: if agent is active, it's working
|
||||
if (
|
||||
session.status === "spawning" ||
|
||||
session.status === SESSION_STATUS.STUCK ||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
|
|||
pr: raw["pr"],
|
||||
summary: raw["summary"],
|
||||
project: raw["project"],
|
||||
agent: raw["agent"],
|
||||
createdAt: raw["createdAt"],
|
||||
runtimeHandle: raw["runtimeHandle"],
|
||||
dashboardPort: raw["dashboardPort"] ? Number(raw["dashboardPort"]) : undefined,
|
||||
|
|
@ -139,6 +140,7 @@ export function writeMetadata(
|
|||
if (metadata.pr) data["pr"] = metadata.pr;
|
||||
if (metadata.summary) data["summary"] = metadata.summary;
|
||||
if (metadata.project) data["project"] = metadata.project;
|
||||
if (metadata.agent) data["agent"] = metadata.agent;
|
||||
if (metadata.createdAt) data["createdAt"] = metadata.createdAt;
|
||||
if (metadata.runtimeHandle) data["runtimeHandle"] = metadata.runtimeHandle;
|
||||
if (metadata.dashboardPort !== undefined)
|
||||
|
|
|
|||
|
|
@ -210,9 +210,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
}
|
||||
|
||||
/** Resolve which plugins to use for a project. */
|
||||
function resolvePlugins(project: ProjectConfig) {
|
||||
function resolvePlugins(project: ProjectConfig, agentOverride?: string) {
|
||||
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
|
||||
const agent = registry.get<Agent>("agent", project.agent ?? config.defaults.agent);
|
||||
const agent = registry.get<Agent>("agent", agentOverride ?? project.agent ?? config.defaults.agent);
|
||||
const workspace = registry.get<Workspace>(
|
||||
"workspace",
|
||||
project.workspace ?? config.defaults.workspace,
|
||||
|
|
@ -322,6 +322,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
if (!plugins.runtime) {
|
||||
throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`);
|
||||
}
|
||||
|
||||
// Allow --agent override to swap the agent plugin for this session
|
||||
if (spawnConfig.agent) {
|
||||
const overrideAgent = registry.get<Agent>("agent", spawnConfig.agent);
|
||||
if (!overrideAgent) {
|
||||
throw new Error(`Agent plugin '${spawnConfig.agent}' not found`);
|
||||
}
|
||||
plugins.agent = overrideAgent;
|
||||
}
|
||||
|
||||
if (!plugins.agent) {
|
||||
throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`);
|
||||
}
|
||||
|
|
@ -515,6 +525,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
tmuxName, // Store tmux name for mapping
|
||||
issue: spawnConfig.issueId,
|
||||
project: spawnConfig.projectId,
|
||||
agent: plugins.agent.name, // Persist agent name for lifecycle manager
|
||||
createdAt: new Date().toISOString(),
|
||||
runtimeHandle: JSON.stringify(handle),
|
||||
});
|
||||
|
|
@ -693,7 +704,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
|
||||
const session = metadataToSession(sessionName, raw, createdAt, modifiedAt);
|
||||
|
||||
const plugins = resolvePlugins(project);
|
||||
const plugins = resolvePlugins(project, raw["agent"]);
|
||||
// Cap per-session enrichment at 2s — subprocess calls (tmux/ps) can be
|
||||
// slow under load. If we time out, session keeps its metadata values.
|
||||
const enrichTimeout = new Promise<void>((resolve) => setTimeout(resolve, 2_000));
|
||||
|
|
@ -727,7 +738,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
|
||||
const session = metadataToSession(sessionId, raw, createdAt, modifiedAt);
|
||||
|
||||
const plugins = resolvePlugins(project);
|
||||
const plugins = resolvePlugins(project, raw["agent"]);
|
||||
await ensureHandleAndEnrich(session, sessionId, project, plugins);
|
||||
|
||||
return session;
|
||||
|
|
@ -967,7 +978,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
// session (status "working", agent exited) would not be detected as terminal
|
||||
// and isRestorable would reject it.
|
||||
const session = metadataToSession(sessionId, raw);
|
||||
const plugins = resolvePlugins(project);
|
||||
const plugins = resolvePlugins(project, raw["agent"]);
|
||||
await enrichSessionWithRuntimeState(session, plugins, true);
|
||||
|
||||
// 3. Validate restorability
|
||||
|
|
|
|||
|
|
@ -176,6 +176,8 @@ export interface SessionSpawnConfig {
|
|||
issueId?: string;
|
||||
branch?: string;
|
||||
prompt?: string;
|
||||
/** Override the agent plugin for this session (e.g. "codex", "claude-code") */
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
/** Config for creating an orchestrator session */
|
||||
|
|
@ -963,6 +965,7 @@ export interface SessionMetadata {
|
|||
pr?: string;
|
||||
summary?: string;
|
||||
project?: string;
|
||||
agent?: string; // Agent plugin name (e.g. "codex", "claude-code") — persisted for lifecycle
|
||||
createdAt?: string;
|
||||
runtimeHandle?: string;
|
||||
restoredAt?: string;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted mocks
|
||||
// Hoisted mocks — available inside vi.mock factories
|
||||
// ---------------------------------------------------------------------------
|
||||
const { mockExecFileAsync } = vi.hoisted(() => ({
|
||||
const {
|
||||
mockExecFileAsync,
|
||||
mockWriteFile,
|
||||
mockMkdir,
|
||||
mockReadFile,
|
||||
mockRename,
|
||||
mockHomedir,
|
||||
} = vi.hoisted(() => ({
|
||||
mockExecFileAsync: vi.fn(),
|
||||
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockReadFile: vi.fn(),
|
||||
mockRename: vi.fn().mockResolvedValue(undefined),
|
||||
mockHomedir: vi.fn(() => "/mock/home"),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
|
|
@ -15,10 +27,29 @@ vi.mock("node:child_process", () => {
|
|||
return { execFile: fn };
|
||||
});
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
writeFile: mockWriteFile,
|
||||
mkdir: mockMkdir,
|
||||
readFile: mockReadFile,
|
||||
rename: mockRename,
|
||||
}));
|
||||
|
||||
vi.mock("node:crypto", () => ({
|
||||
randomBytes: () => ({ toString: () => "abc123" }),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
homedir: mockHomedir,
|
||||
}));
|
||||
|
||||
import { create, manifest, default as defaultExport } from "./index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
|
|
@ -62,8 +93,10 @@ function makeLaunchConfig(overrides: Partial<AgentLaunchConfig> = {}): AgentLaun
|
|||
}
|
||||
|
||||
function mockTmuxWithProcess(processName: string, found = true) {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
|
||||
mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd === "tmux" && args[0] === "list-panes") {
|
||||
return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
|
||||
}
|
||||
if (cmd === "ps") {
|
||||
const line = found ? ` 789 ttys003 ${processName}` : " 789 ttys003 bash";
|
||||
return Promise.resolve({
|
||||
|
|
@ -71,12 +104,13 @@ function mockTmuxWithProcess(processName: string, found = true) {
|
|||
stderr: "",
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
return Promise.reject(new Error(`Unexpected: ${cmd} ${args.join(" ")}`));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHomedir.mockReturnValue("/mock/home");
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -114,9 +148,9 @@ describe("getLaunchCommand", () => {
|
|||
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("codex");
|
||||
});
|
||||
|
||||
it("includes --approval-mode full-auto when permissions=skip", () => {
|
||||
it("includes --full-auto when permissions=skip", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" }));
|
||||
expect(cmd).toContain("--approval-mode full-auto");
|
||||
expect(cmd).toContain("--full-auto");
|
||||
});
|
||||
|
||||
it("includes --model with shell-escaped value", () => {
|
||||
|
|
@ -133,7 +167,7 @@ describe("getLaunchCommand", () => {
|
|||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ permissions: "skip", model: "o3", prompt: "Go" }),
|
||||
);
|
||||
expect(cmd).toBe("codex --approval-mode full-auto --model 'o3' -- 'Go'");
|
||||
expect(cmd).toBe("codex --full-auto --model 'o3' -- 'Go'");
|
||||
});
|
||||
|
||||
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
|
||||
|
|
@ -141,10 +175,37 @@ describe("getLaunchCommand", () => {
|
|||
expect(cmd).toContain("-- 'it'\\''s broken'");
|
||||
});
|
||||
|
||||
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("-- '$(rm -rf /); `evil`; $HOME'");
|
||||
});
|
||||
|
||||
it("includes -c model_instructions_file when systemPromptFile is set", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md" }));
|
||||
expect(cmd).toContain("-c model_instructions_file='/tmp/prompt.md'");
|
||||
});
|
||||
|
||||
it("prefers systemPromptFile over systemPrompt", () => {
|
||||
const cmd = agent.getLaunchCommand(
|
||||
makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md", systemPrompt: "Ignored" }),
|
||||
);
|
||||
expect(cmd).toContain("model_instructions_file='/tmp/prompt.md'");
|
||||
expect(cmd).not.toContain("'Ignored'");
|
||||
});
|
||||
|
||||
it("includes -c developer_instructions when systemPrompt is set", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: "Be helpful" }));
|
||||
expect(cmd).toContain("-c developer_instructions='Be helpful'");
|
||||
});
|
||||
|
||||
it("omits optional flags when not provided", () => {
|
||||
const cmd = agent.getLaunchCommand(makeLaunchConfig());
|
||||
expect(cmd).not.toContain("--approval-mode");
|
||||
expect(cmd).not.toContain("--full-auto");
|
||||
expect(cmd).not.toContain("--model");
|
||||
expect(cmd).not.toContain("-c");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -169,6 +230,27 @@ describe("getEnvironment", () => {
|
|||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["AO_ISSUE_ID"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prepends ~/.ao/bin to PATH for shell wrappers", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toMatch(/^.*\/\.ao\/bin:/);
|
||||
});
|
||||
|
||||
it("PATH starts with the ao bin dir specifically", () => {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]?.startsWith("/mock/home/.ao/bin:")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to /usr/bin:/bin when process.env.PATH is undefined", () => {
|
||||
const originalPath = process.env["PATH"];
|
||||
delete process.env["PATH"];
|
||||
try {
|
||||
const env = agent.getEnvironment(makeLaunchConfig());
|
||||
expect(env["PATH"]).toContain("/usr/bin:/bin");
|
||||
} finally {
|
||||
process.env["PATH"] = originalPath;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -187,6 +269,11 @@ describe("isProcessRunning", () => {
|
|||
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 handle with alive PID", async () => {
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true);
|
||||
|
|
@ -205,6 +292,8 @@ describe("isProcessRunning", () => {
|
|||
it("returns false for unknown runtime without PID", async () => {
|
||||
const handle: RuntimeHandle = { id: "x", runtimeName: "other", data: {} };
|
||||
expect(await agent.isProcessRunning(handle)).toBe(false);
|
||||
// Must NOT call external commands — could match wrong session
|
||||
expect(mockExecFileAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false on tmux command failure", async () => {
|
||||
|
|
@ -222,8 +311,8 @@ describe("isProcessRunning", () => {
|
|||
});
|
||||
|
||||
it("finds codex on any pane in multi-pane session", async () => {
|
||||
mockExecFileAsync.mockImplementation((cmd: string) => {
|
||||
if (cmd === "tmux") {
|
||||
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") {
|
||||
|
|
@ -236,6 +325,33 @@ describe("isProcessRunning", () => {
|
|||
});
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match similar process names like codex-something", 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/codex-helper\n",
|
||||
stderr: "",
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("unexpected"));
|
||||
});
|
||||
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
||||
});
|
||||
|
||||
it("handles string PID by converting to number", 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 non-numeric PID", async () => {
|
||||
expect(await agent.isProcessRunning(makeProcessHandle("not-a-pid"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -244,6 +360,7 @@ describe("isProcessRunning", () => {
|
|||
describe("detectActivity", () => {
|
||||
const agent = create();
|
||||
|
||||
// -- Idle states --
|
||||
it("returns idle for empty terminal output", () => {
|
||||
expect(agent.detectActivity("")).toBe("idle");
|
||||
});
|
||||
|
|
@ -252,9 +369,113 @@ describe("detectActivity", () => {
|
|||
expect(agent.detectActivity(" \n ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns active for non-empty terminal output", () => {
|
||||
it("returns idle when last line is a bare > prompt", () => {
|
||||
expect(agent.detectActivity("some output\n> ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle when last line is a bare $ prompt", () => {
|
||||
expect(agent.detectActivity("some output\n$ ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle when last line is a bare # prompt", () => {
|
||||
expect(agent.detectActivity("some output\n# ")).toBe("idle");
|
||||
});
|
||||
|
||||
it("returns idle when prompt follows historical activity indicators", () => {
|
||||
// Key regression test: historical active output in the buffer
|
||||
// should NOT override an idle prompt on the last line.
|
||||
expect(agent.detectActivity("✶ Reading files\nDone.\n> ")).toBe("idle");
|
||||
expect(agent.detectActivity("Working on task (esc to interrupt)\nFinished.\n$ ")).toBe("idle");
|
||||
});
|
||||
|
||||
// -- Waiting input states --
|
||||
it("returns waiting_input for approval required text", () => {
|
||||
expect(agent.detectActivity("some output\napproval required\n")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input for (y)es / (n)o prompt", () => {
|
||||
expect(agent.detectActivity("Do you want to continue?\n(y)es / (n)o\n")).toBe("waiting_input");
|
||||
});
|
||||
|
||||
it("returns waiting_input when permission prompt follows historical activity", () => {
|
||||
// Permission prompt at the bottom should NOT be overridden by historical
|
||||
// spinner/esc output higher in the buffer.
|
||||
expect(
|
||||
agent.detectActivity("✶ Writing files\nDone.\napproval required\n"),
|
||||
).toBe("waiting_input");
|
||||
expect(
|
||||
agent.detectActivity("Working (esc to interrupt)\nFinished\n(y)es / (n)o\n"),
|
||||
).toBe("waiting_input");
|
||||
});
|
||||
|
||||
// -- Active states --
|
||||
it("returns active for non-empty terminal output with no special patterns", () => {
|
||||
expect(agent.detectActivity("codex is running some task\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active when (esc to interrupt) is present", () => {
|
||||
expect(agent.detectActivity("Working on task (esc to interrupt)\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active for spinner symbols with -ing words", () => {
|
||||
expect(agent.detectActivity("✶ Reading files\n")).toBe("active");
|
||||
expect(agent.detectActivity("⏺ Writing to disk\n")).toBe("active");
|
||||
expect(agent.detectActivity("✽ Searching codebase\n")).toBe("active");
|
||||
expect(agent.detectActivity("⏳ Installing packages\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active (not idle) for spinner symbol without -ing word", () => {
|
||||
// Spinner symbols alone without -ing words should still fall through to active
|
||||
expect(agent.detectActivity("✶ done\n")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns active for multi-line output with activity in the middle", () => {
|
||||
expect(agent.detectActivity("Starting\n(esc to interrupt)\nstill going\n")).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// getActivityState
|
||||
// =========================================================================
|
||||
describe("getActivityState", () => {
|
||||
const agent = create();
|
||||
|
||||
it("returns exited when no runtimeHandle", async () => {
|
||||
const session = makeSession({ runtimeHandle: null });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result).toEqual({ state: "exited" });
|
||||
});
|
||||
|
||||
it("returns exited when process is not running", async () => {
|
||||
mockExecFileAsync.mockRejectedValue(new Error("tmux not running"));
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result).toEqual({ state: "exited" });
|
||||
});
|
||||
|
||||
it("returns null (unknown) when process is running", async () => {
|
||||
mockTmuxWithProcess("codex");
|
||||
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
|
||||
expect(await agent.getActivityState(session)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns exited when process handle has dead PID", async () => {
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw new Error("ESRCH");
|
||||
});
|
||||
const session = makeSession({ runtimeHandle: makeProcessHandle(999) });
|
||||
const result = await agent.getActivityState(session);
|
||||
expect(result).toEqual({ state: "exited" });
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not include timestamp in exited state", async () => {
|
||||
const session = makeSession({ runtimeHandle: null });
|
||||
const result = await agent.getActivityState(session);
|
||||
// The Codex implementation returns { state: "exited" } without timestamp
|
||||
expect(result).toEqual({ state: "exited" });
|
||||
expect(result?.timestamp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -267,4 +488,422 @@ describe("getSessionInfo", () => {
|
|||
expect(await agent.getSessionInfo(makeSession())).toBeNull();
|
||||
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/some/path" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null even with null workspacePath", async () => {
|
||||
expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// setupWorkspaceHooks — file writing behavior
|
||||
// =========================================================================
|
||||
describe("setupWorkspaceHooks", () => {
|
||||
const agent = create();
|
||||
|
||||
it("has setupWorkspaceHooks method", () => {
|
||||
expect(typeof agent.setupWorkspaceHooks).toBe("function");
|
||||
});
|
||||
|
||||
it("creates ~/.ao/bin directory", async () => {
|
||||
// Version marker doesn't exist — triggers full install
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
expect(mockMkdir).toHaveBeenCalledWith("/mock/home/.ao/bin", { recursive: true });
|
||||
});
|
||||
|
||||
it("writes ao-metadata-helper.sh with executable permissions via atomic write", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Atomic write: writes to .tmp file first, then renames
|
||||
const helperWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("ao-metadata-helper.sh.tmp."),
|
||||
);
|
||||
expect(helperWriteCall).toBeDefined();
|
||||
expect(helperWriteCall![1]).toContain("update_ao_metadata()");
|
||||
expect(helperWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 });
|
||||
|
||||
// Then renamed to final path
|
||||
const helperRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith("ao-metadata-helper.sh"),
|
||||
);
|
||||
expect(helperRenameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("writes gh and git wrappers atomically when version marker is missing", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// gh wrapper: written to temp, then renamed
|
||||
const ghWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/gh.tmp."),
|
||||
);
|
||||
expect(ghWriteCall).toBeDefined();
|
||||
expect(ghWriteCall![1]).toContain("ao gh wrapper");
|
||||
|
||||
const ghRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith("/gh"),
|
||||
);
|
||||
expect(ghRenameCall).toBeDefined();
|
||||
|
||||
// git wrapper: written to temp, then renamed
|
||||
const gitWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/git.tmp."),
|
||||
);
|
||||
expect(gitWriteCall).toBeDefined();
|
||||
expect(gitWriteCall![1]).toContain("ao git wrapper");
|
||||
|
||||
const gitRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith("/git"),
|
||||
);
|
||||
expect(gitRenameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets executable permissions on gh and git wrappers via writeFile mode", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
const ghWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/gh.tmp."),
|
||||
);
|
||||
expect(ghWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 });
|
||||
|
||||
const gitWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/git.tmp."),
|
||||
);
|
||||
expect(gitWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 });
|
||||
});
|
||||
|
||||
it("skips wrapper writes when version marker matches", async () => {
|
||||
// First call for version marker — matches current version
|
||||
// Second call for AGENTS.md — file doesn't exist
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.1.0");
|
||||
}
|
||||
// AGENTS.md read attempt
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Should still write the metadata helper (always written)
|
||||
const helperWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("ao-metadata-helper.sh.tmp."),
|
||||
);
|
||||
expect(helperWriteCall).toBeDefined();
|
||||
|
||||
// But should NOT write gh/git wrappers (version matches)
|
||||
const ghWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes("/gh.tmp."),
|
||||
);
|
||||
expect(ghWriteCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes version marker after installing wrappers", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Version marker is also atomically written
|
||||
const versionWriteCall = mockWriteFile.mock.calls.find(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes(".ao-version.tmp."),
|
||||
);
|
||||
expect(versionWriteCall).toBeDefined();
|
||||
expect(versionWriteCall![1]).toBe("0.1.0");
|
||||
|
||||
const versionRenameCall = mockRename.mock.calls.find(
|
||||
(call: string[]) => typeof call[1] === "string" && call[1].endsWith(".ao-version"),
|
||||
);
|
||||
expect(versionRenameCall).toBeDefined();
|
||||
});
|
||||
|
||||
it("appends ao section to AGENTS.md when not present", async () => {
|
||||
// Version marker matches (skip wrapper install)
|
||||
// AGENTS.md exists without ao section
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.1.0");
|
||||
}
|
||||
if (typeof path === "string" && path.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve("# Existing Content\n\nSome stuff here.\n");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
const agentsMdCall = mockWriteFile.mock.calls.find(
|
||||
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdCall).toBeDefined();
|
||||
expect(agentsMdCall![1]).toContain("Agent Orchestrator (ao) Session");
|
||||
expect(agentsMdCall![1]).toContain("# Existing Content");
|
||||
});
|
||||
|
||||
it("creates AGENTS.md if it does not exist", async () => {
|
||||
// Version marker matches, AGENTS.md doesn't exist
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.1.0");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
const agentsMdCall = mockWriteFile.mock.calls.find(
|
||||
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
|
||||
);
|
||||
expect(agentsMdCall).toBeDefined();
|
||||
expect(agentsMdCall![1]).toContain("Agent Orchestrator (ao) Session");
|
||||
});
|
||||
|
||||
it("uses atomic write (temp + rename) to prevent partial reads from concurrent sessions", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// Every wrapper file should be written to a .tmp file first, then renamed
|
||||
// This ensures concurrent readers never see a partially written file
|
||||
const tmpWrites = mockWriteFile.mock.calls.filter(
|
||||
(call: [string, string, object]) =>
|
||||
typeof call[0] === "string" && call[0].includes(".tmp."),
|
||||
);
|
||||
const renames = mockRename.mock.calls;
|
||||
|
||||
// We expect atomic writes for: helper, gh, git, version marker = 4
|
||||
expect(tmpWrites.length).toBe(4);
|
||||
expect(renames.length).toBe(4);
|
||||
|
||||
// Each rename should move a .tmp file to the final path
|
||||
for (const [src, dst] of renames) {
|
||||
expect(src).toContain(".tmp.");
|
||||
expect(dst).not.toContain(".tmp.");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate ao section in AGENTS.md if already present", async () => {
|
||||
mockReadFile.mockImplementation((path: string) => {
|
||||
if (typeof path === "string" && path.endsWith(".ao-version")) {
|
||||
return Promise.resolve("0.1.0");
|
||||
}
|
||||
if (typeof path === "string" && path.endsWith("AGENTS.md")) {
|
||||
return Promise.resolve("# Existing\n\n## Agent Orchestrator (ao) Session\n\nAlready here.\n");
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
const agentsMdCall = mockWriteFile.mock.calls.find(
|
||||
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
|
||||
);
|
||||
// Should NOT write AGENTS.md since the section already exists
|
||||
expect(agentsMdCall).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// postLaunchSetup
|
||||
// =========================================================================
|
||||
describe("postLaunchSetup", () => {
|
||||
const agent = create();
|
||||
|
||||
it("has postLaunchSetup method", () => {
|
||||
expect(typeof agent.postLaunchSetup).toBe("function");
|
||||
});
|
||||
|
||||
it("runs setup when session has workspacePath", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" }));
|
||||
expect(mockMkdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when session has no workspacePath", async () => {
|
||||
await agent.postLaunchSetup!(makeSession({ workspacePath: undefined }));
|
||||
expect(mockMkdir).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Shell wrapper content verification
|
||||
// =========================================================================
|
||||
describe("shell wrapper content", () => {
|
||||
const agent = create();
|
||||
|
||||
beforeEach(() => {
|
||||
// Force wrapper installation by making version marker miss
|
||||
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
||||
});
|
||||
|
||||
async function getWrapperContent(name: string): Promise<string> {
|
||||
await agent.setupWorkspaceHooks!("/workspace/test", {
|
||||
dataDir: "/data",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
|
||||
// With atomic writes, content is written to a .tmp. file
|
||||
const call = mockWriteFile.mock.calls.find(
|
||||
(c: [string, string, object]) =>
|
||||
typeof c[0] === "string" && c[0].includes(`/${name}.tmp.`),
|
||||
);
|
||||
return call ? call[1] as string : "";
|
||||
}
|
||||
|
||||
describe("metadata helper", () => {
|
||||
it("contains update_ao_metadata function", async () => {
|
||||
const content = await getWrapperContent("ao-metadata-helper.sh");
|
||||
expect(content).toContain("update_ao_metadata()");
|
||||
});
|
||||
|
||||
it("uses AO_DATA_DIR and AO_SESSION env vars", async () => {
|
||||
const content = await getWrapperContent("ao-metadata-helper.sh");
|
||||
expect(content).toContain("AO_DATA_DIR");
|
||||
expect(content).toContain("AO_SESSION");
|
||||
});
|
||||
|
||||
it("escapes sed metacharacters in values", async () => {
|
||||
const content = await getWrapperContent("ao-metadata-helper.sh");
|
||||
// Should contain the sed escaping logic for &, |, and \
|
||||
expect(content).toContain("escaped_value");
|
||||
expect(content).toMatch(/sed.*\\\\&/);
|
||||
});
|
||||
|
||||
it("uses atomic temp file + mv pattern", async () => {
|
||||
const content = await getWrapperContent("ao-metadata-helper.sh");
|
||||
expect(content).toContain("temp_file");
|
||||
expect(content).toContain("mv");
|
||||
});
|
||||
|
||||
it("validates session name has no path separators", async () => {
|
||||
const content = await getWrapperContent("ao-metadata-helper.sh");
|
||||
// Rejects session names containing / or ..
|
||||
expect(content).toContain("*/*");
|
||||
expect(content).toContain("*..*");
|
||||
});
|
||||
|
||||
it("validates ao_dir is an absolute path under expected locations", async () => {
|
||||
const content = await getWrapperContent("ao-metadata-helper.sh");
|
||||
// Only allows paths under $HOME/.ao/, $HOME/.agent-orchestrator/, or /tmp/
|
||||
expect(content).toContain('$HOME"/.ao/*');
|
||||
expect(content).toContain('$HOME"/.agent-orchestrator/*');
|
||||
expect(content).toContain("/tmp/*");
|
||||
});
|
||||
|
||||
it("resolves symlinks and verifies file stays within ao_dir", async () => {
|
||||
const content = await getWrapperContent("ao-metadata-helper.sh");
|
||||
expect(content).toContain("pwd -P");
|
||||
expect(content).toContain("real_ao_dir");
|
||||
expect(content).toContain("real_dir");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gh wrapper", () => {
|
||||
it("uses grep -Fxv for PATH cleaning (not regex grep)", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("grep -Fxv");
|
||||
expect(content).not.toMatch(/grep -v "\^\$ao_bin_dir\$"/);
|
||||
});
|
||||
|
||||
it("only captures output for pr/create and pr/merge", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("pr/create|pr/merge");
|
||||
});
|
||||
|
||||
it("uses exec for non-PR commands (transparent passthrough)", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain('exec "$real_gh"');
|
||||
});
|
||||
|
||||
it("extracts PR URL from gh pr create output", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("https://github");
|
||||
expect(content).toContain("update_ao_metadata pr");
|
||||
});
|
||||
|
||||
it("updates status to merged on gh pr merge", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("update_ao_metadata status merged");
|
||||
});
|
||||
|
||||
it("cleans up temp file on exit", async () => {
|
||||
const content = await getWrapperContent("gh");
|
||||
expect(content).toContain("trap");
|
||||
expect(content).toContain("rm -f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("git wrapper", () => {
|
||||
it("uses grep -Fxv for PATH cleaning (not regex grep)", async () => {
|
||||
const content = await getWrapperContent("git");
|
||||
expect(content).toContain("grep -Fxv");
|
||||
expect(content).not.toMatch(/grep -v "\^\$ao_bin_dir\$"/);
|
||||
});
|
||||
|
||||
it("captures branch name from checkout -b", async () => {
|
||||
const content = await getWrapperContent("git");
|
||||
expect(content).toContain("checkout/-b");
|
||||
expect(content).toContain("update_ao_metadata branch");
|
||||
});
|
||||
|
||||
it("captures branch name from switch -c", async () => {
|
||||
const content = await getWrapperContent("git");
|
||||
expect(content).toContain("switch/-c");
|
||||
});
|
||||
|
||||
it("only updates metadata on success (exit code 0)", async () => {
|
||||
const content = await getWrapperContent("git");
|
||||
expect(content).toContain("exit_code -eq 0");
|
||||
});
|
||||
|
||||
it("sources the metadata helper", async () => {
|
||||
const content = await getWrapperContent("git");
|
||||
expect(content).toContain("source");
|
||||
expect(content).toContain("ao-metadata-helper.sh");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,17 +3,25 @@ import {
|
|||
type Agent,
|
||||
type AgentSessionInfo,
|
||||
type AgentLaunchConfig,
|
||||
type ActivityDetection,
|
||||
type ActivityState,
|
||||
type ActivityDetection,
|
||||
type PluginModule,
|
||||
type RuntimeHandle,
|
||||
type Session,
|
||||
type WorkspaceHooksConfig,
|
||||
} from "@composio/ao-core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { writeFile, mkdir, readFile, rename } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Shared bin directory for ao shell wrappers (prepended to PATH) */
|
||||
const AO_BIN_DIR = join(homedir(), ".ao", "bin");
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest
|
||||
// =============================================================================
|
||||
|
|
@ -25,6 +33,243 @@ export const manifest = {
|
|||
version: "0.1.0",
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Shell Wrappers (automatic metadata updates — like Claude Code's PostToolUse)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Helper script sourced by both gh and git wrappers.
|
||||
* Provides update_ao_metadata() for writing key=value to the session file.
|
||||
*/
|
||||
/* eslint-disable no-useless-escape -- \$ escapes are intentional: bash scripts in JS template literals */
|
||||
const AO_METADATA_HELPER = `#!/usr/bin/env bash
|
||||
# ao-metadata-helper — shared by gh/git wrappers
|
||||
# Provides: update_ao_metadata <key> <value>
|
||||
|
||||
update_ao_metadata() {
|
||||
local key="\$1" value="\$2"
|
||||
local ao_dir="\${AO_DATA_DIR:-}"
|
||||
local ao_session="\${AO_SESSION:-}"
|
||||
|
||||
[[ -z "\$ao_dir" || -z "\$ao_session" ]] && return 0
|
||||
|
||||
# Validate: session name must not contain path separators or traversal
|
||||
case "\$ao_session" in
|
||||
*/* | *..*) return 0 ;;
|
||||
esac
|
||||
|
||||
# Validate: ao_dir must be an absolute path under known ao directories or /tmp
|
||||
case "\$ao_dir" in
|
||||
"\$HOME"/.ao/* | "\$HOME"/.agent-orchestrator/* | /tmp/*) ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
|
||||
local metadata_file="\$ao_dir/\$ao_session"
|
||||
|
||||
# Resolve and verify the file is still within ao_dir
|
||||
local real_dir real_ao_dir
|
||||
real_ao_dir="\$(cd "\$ao_dir" 2>/dev/null && pwd -P)" || return 0
|
||||
real_dir="\$(cd "\$(dirname "\$metadata_file")" 2>/dev/null && pwd -P)" || return 0
|
||||
[[ "\$real_dir" == "\$real_ao_dir"* ]] || return 0
|
||||
|
||||
[[ -f "\$metadata_file" ]] || return 0
|
||||
|
||||
local temp_file="\${metadata_file}.tmp.\$\$"
|
||||
|
||||
# Strip newlines from value to prevent metadata line injection
|
||||
local clean_value="\$(printf '%s' "\$value" | tr -d '\\n')"
|
||||
|
||||
# Escape sed metacharacters in value (& expands to matched text, | breaks delimiter)
|
||||
local escaped_value="\$(printf '%s' "\$clean_value" | sed 's/[&|\\\\]/\\\\&/g')"
|
||||
|
||||
if grep -q "^\${key}=" "\$metadata_file" 2>/dev/null; then
|
||||
sed "s|^\${key}=.*|\${key}=\${escaped_value}|" "\$metadata_file" > "\$temp_file"
|
||||
else
|
||||
cp "\$metadata_file" "\$temp_file"
|
||||
printf '%s=%s\\n' "\$key" "\$clean_value" >> "\$temp_file"
|
||||
fi
|
||||
|
||||
mv "\$temp_file" "\$metadata_file"
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* gh wrapper — intercepts `gh pr create` and `gh pr merge` to auto-update
|
||||
* session metadata. All other commands pass through transparently.
|
||||
*/
|
||||
const GH_WRAPPER = `#!/usr/bin/env bash
|
||||
# ao gh wrapper — auto-updates session metadata on PR operations
|
||||
|
||||
# Find real gh by removing our wrapper directory from PATH
|
||||
ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||
clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')"
|
||||
clean_path="\${clean_path%:}"
|
||||
real_gh="\$(PATH="\$clean_path" command -v gh 2>/dev/null)"
|
||||
|
||||
if [[ -z "\$real_gh" ]]; then
|
||||
echo "ao-wrapper: gh not found in PATH" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Source the metadata helper
|
||||
source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
|
||||
|
||||
# Only capture output for commands we need to parse (pr/create, pr/merge).
|
||||
# All other commands pass through transparently without stream merging.
|
||||
case "\$1/\$2" in
|
||||
pr/create|pr/merge)
|
||||
tmpout="\$(mktemp)"
|
||||
trap 'rm -f "\$tmpout"' EXIT
|
||||
|
||||
"\$real_gh" "\$@" 2>&1 | tee "\$tmpout"
|
||||
exit_code=\${PIPESTATUS[0]}
|
||||
|
||||
if [[ \$exit_code -eq 0 ]]; then
|
||||
output="\$(cat "\$tmpout")"
|
||||
case "\$1/\$2" in
|
||||
pr/create)
|
||||
pr_url="\$(echo "\$output" | grep -Eo 'https://github\\.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)"
|
||||
if [[ -n "\$pr_url" ]]; then
|
||||
update_ao_metadata pr "\$pr_url"
|
||||
update_ao_metadata status pr_open
|
||||
fi
|
||||
;;
|
||||
pr/merge)
|
||||
update_ao_metadata status merged
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exit \$exit_code
|
||||
;;
|
||||
*)
|
||||
exec "\$real_gh" "\$@"
|
||||
;;
|
||||
esac
|
||||
`;
|
||||
|
||||
/**
|
||||
* git wrapper — intercepts branch creation commands to auto-update metadata.
|
||||
* All other commands pass through transparently.
|
||||
*/
|
||||
const GIT_WRAPPER = `#!/usr/bin/env bash
|
||||
# ao git wrapper — auto-updates session metadata on branch operations
|
||||
|
||||
# Find real git by removing our wrapper directory from PATH
|
||||
ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||
clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')"
|
||||
clean_path="\${clean_path%:}"
|
||||
real_git="\$(PATH="\$clean_path" command -v git 2>/dev/null)"
|
||||
|
||||
if [[ -z "\$real_git" ]]; then
|
||||
echo "ao-wrapper: git not found in PATH" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
# Source the metadata helper
|
||||
source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true
|
||||
|
||||
# Run real git
|
||||
"\$real_git" "\$@"
|
||||
exit_code=\$?
|
||||
|
||||
# Only update metadata on success
|
||||
if [[ \$exit_code -eq 0 ]]; then
|
||||
case "\$1/\$2" in
|
||||
checkout/-b)
|
||||
update_ao_metadata branch "\$3"
|
||||
;;
|
||||
switch/-c)
|
||||
update_ao_metadata branch "\$3"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exit \$exit_code
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// Workspace Setup
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Section appended to AGENTS.md as a secondary signal. The PATH-based wrappers
|
||||
* handle metadata updates automatically, but AGENTS.md reinforces the intent
|
||||
* and helps if the wrappers are bypassed.
|
||||
*/
|
||||
const AO_AGENTS_MD_SECTION = `
|
||||
## Agent Orchestrator (ao) Session
|
||||
|
||||
You are running inside an Agent Orchestrator managed workspace.
|
||||
Session metadata is updated automatically via shell wrappers.
|
||||
|
||||
If automatic updates fail, you can manually update metadata:
|
||||
\`\`\`bash
|
||||
~/.ao/bin/ao-metadata-helper.sh # sourced automatically
|
||||
# Then call: update_ao_metadata <key> <value>
|
||||
\`\`\`
|
||||
`;
|
||||
/* eslint-enable no-useless-escape */
|
||||
|
||||
/**
|
||||
* Atomically write a file by writing to a temp file in the same directory,
|
||||
* then renaming. This prevents concurrent sessions from reading partially
|
||||
* written wrapper scripts.
|
||||
*/
|
||||
async function atomicWriteFile(filePath: string, content: string, mode: number): Promise<void> {
|
||||
const suffix = randomBytes(6).toString("hex");
|
||||
const tmpPath = `${filePath}.tmp.${suffix}`;
|
||||
await writeFile(tmpPath, content, { encoding: "utf-8", mode });
|
||||
await rename(tmpPath, filePath);
|
||||
}
|
||||
|
||||
async function setupCodexWorkspace(workspacePath: string): Promise<void> {
|
||||
// 1. Write shared wrappers to ~/.ao/bin/
|
||||
await mkdir(AO_BIN_DIR, { recursive: true });
|
||||
|
||||
await atomicWriteFile(
|
||||
join(AO_BIN_DIR, "ao-metadata-helper.sh"),
|
||||
AO_METADATA_HELPER,
|
||||
0o755,
|
||||
);
|
||||
|
||||
// Only write wrappers if they don't exist or are outdated (check marker)
|
||||
const markerPath = join(AO_BIN_DIR, ".ao-version");
|
||||
const currentVersion = "0.1.0";
|
||||
let needsUpdate = true;
|
||||
try {
|
||||
const existing = await readFile(markerPath, "utf-8");
|
||||
if (existing.trim() === currentVersion) needsUpdate = false;
|
||||
} catch {
|
||||
// File doesn't exist — needs update
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
// Write wrappers atomically, then write the version marker last.
|
||||
// If we crash between wrapper writes and marker write, the next
|
||||
// invocation will redo the writes (safe: wrappers are idempotent).
|
||||
await atomicWriteFile(join(AO_BIN_DIR, "gh"), GH_WRAPPER, 0o755);
|
||||
await atomicWriteFile(join(AO_BIN_DIR, "git"), GIT_WRAPPER, 0o755);
|
||||
await atomicWriteFile(markerPath, currentVersion, 0o644);
|
||||
}
|
||||
|
||||
// 2. Append ao section to AGENTS.md (create if missing, skip if already present)
|
||||
const agentsMdPath = join(workspacePath, "AGENTS.md");
|
||||
let existing = "";
|
||||
try {
|
||||
existing = await readFile(agentsMdPath, "utf-8");
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
|
||||
if (!existing.includes("Agent Orchestrator (ao) Session")) {
|
||||
const content = existing
|
||||
? existing.trimEnd() + "\n" + AO_AGENTS_MD_SECTION
|
||||
: AO_AGENTS_MD_SECTION.trimStart();
|
||||
await writeFile(agentsMdPath, content, "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Agent Implementation
|
||||
// =============================================================================
|
||||
|
|
@ -38,7 +283,7 @@ function createCodexAgent(): Agent {
|
|||
const parts: string[] = ["codex"];
|
||||
|
||||
if (config.permissions === "skip") {
|
||||
parts.push("--approval-mode", "full-auto");
|
||||
parts.push("--full-auto");
|
||||
}
|
||||
|
||||
if (config.model) {
|
||||
|
|
@ -46,9 +291,11 @@ function createCodexAgent(): Agent {
|
|||
}
|
||||
|
||||
if (config.systemPromptFile) {
|
||||
parts.push("--system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`);
|
||||
// Codex reads developer instructions from a file via config override
|
||||
parts.push("-c", `model_instructions_file=${shellEscape(config.systemPromptFile)}`);
|
||||
} else if (config.systemPrompt) {
|
||||
parts.push("--system-prompt", shellEscape(config.systemPrompt));
|
||||
// Codex accepts inline developer instructions via config override
|
||||
parts.push("-c", `developer_instructions=${shellEscape(config.systemPrompt)}`);
|
||||
}
|
||||
|
||||
if (config.prompt) {
|
||||
|
|
@ -67,21 +314,39 @@ function createCodexAgent(): Agent {
|
|||
if (config.issueId) {
|
||||
env["AO_ISSUE_ID"] = config.issueId;
|
||||
}
|
||||
|
||||
// Prepend ~/.ao/bin to PATH so our gh/git wrappers intercept commands.
|
||||
// The wrappers strip this directory from PATH before calling the real
|
||||
// binary, so there's no infinite recursion.
|
||||
env["PATH"] = `${AO_BIN_DIR}:${process.env["PATH"] ?? "/usr/bin:/bin"}`;
|
||||
|
||||
return env;
|
||||
},
|
||||
|
||||
detectActivity(terminalOutput: string): ActivityState {
|
||||
if (!terminalOutput.trim()) return "idle";
|
||||
// Codex doesn't have rich terminal output patterns yet
|
||||
|
||||
const lines = terminalOutput.trim().split("\n");
|
||||
const lastLine = lines[lines.length - 1]?.trim() ?? "";
|
||||
|
||||
// If Codex is showing its input prompt, it's idle
|
||||
if (/^[>$#]\s*$/.test(lastLine)) return "idle";
|
||||
|
||||
// Check last few lines for approval prompts
|
||||
const tail = lines.slice(-5).join("\n");
|
||||
if (/approval required/i.test(tail)) return "waiting_input";
|
||||
if (/\(y\)es.*\(n\)o/i.test(tail)) return "waiting_input";
|
||||
|
||||
// Default to active — specific patterns (esc to interrupt, spinner
|
||||
// symbols) all map to "active" so no need to check them individually.
|
||||
return "active";
|
||||
},
|
||||
|
||||
async getActivityState(session: Session, _readyThresholdMs?: number): Promise<ActivityDetection | null> {
|
||||
// Check if process is running first
|
||||
const exitedAt = new Date();
|
||||
if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt };
|
||||
if (!session.runtimeHandle) return { state: "exited" };
|
||||
const running = await this.isProcessRunning(session.runtimeHandle);
|
||||
if (!running) return { state: "exited", timestamp: exitedAt };
|
||||
if (!running) return { state: "exited" };
|
||||
|
||||
// NOTE: Codex stores rollout files in a global ~/.codex/sessions/ directory
|
||||
// without workspace-specific scoping. When multiple Codex sessions run in
|
||||
|
|
@ -148,6 +413,15 @@ function createCodexAgent(): Agent {
|
|||
// Codex doesn't have JSONL session files for introspection yet
|
||||
return null;
|
||||
},
|
||||
|
||||
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
|
||||
await setupCodexWorkspace(workspacePath);
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
await setupCodexWorkspace(session.workspacePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue