2168 lines
79 KiB
TypeScript
2168 lines
79 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type * as Readline from "node:readline";
|
|
import {
|
|
createActivitySignal,
|
|
type Session,
|
|
type RuntimeHandle,
|
|
type AgentLaunchConfig,
|
|
type AgentSpecificConfig,
|
|
} from "@aoagents/ao-core";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hoisted mocks — available inside vi.mock factories
|
|
// ---------------------------------------------------------------------------
|
|
const {
|
|
mockExecFileAsync,
|
|
mockWriteFile,
|
|
mockMkdir,
|
|
mockReadFile,
|
|
mockReaddir,
|
|
mockRename,
|
|
mockStat,
|
|
mockLstat,
|
|
mockOpen,
|
|
mockCreateReadStream,
|
|
mockCreateInterface,
|
|
mockHomedir,
|
|
mockReadLastJsonlEntry,
|
|
mockIsWindows,
|
|
} = vi.hoisted(() => ({
|
|
mockExecFileAsync: vi.fn(),
|
|
mockWriteFile: vi.fn().mockResolvedValue(undefined),
|
|
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
|
mockReadFile: vi.fn(),
|
|
mockReaddir: vi.fn(),
|
|
mockRename: vi.fn().mockResolvedValue(undefined),
|
|
mockStat: vi.fn(),
|
|
mockLstat: vi.fn(),
|
|
mockOpen: vi.fn(),
|
|
mockCreateReadStream: vi.fn(),
|
|
mockCreateInterface: vi.fn(),
|
|
mockHomedir: vi.fn(() => "/mock/home"),
|
|
mockReadLastJsonlEntry: vi.fn(),
|
|
mockIsWindows: vi.fn(() => false),
|
|
}));
|
|
|
|
vi.mock("node:child_process", () => {
|
|
const fn = Object.assign((..._args: unknown[]) => {}, {
|
|
[Symbol.for("nodejs.util.promisify.custom")]: mockExecFileAsync,
|
|
});
|
|
return { execFile: fn };
|
|
});
|
|
|
|
vi.mock("node:fs/promises", () => ({
|
|
writeFile: mockWriteFile,
|
|
mkdir: mockMkdir,
|
|
readFile: mockReadFile,
|
|
readdir: mockReaddir,
|
|
rename: mockRename,
|
|
stat: mockStat,
|
|
lstat: mockLstat,
|
|
open: mockOpen,
|
|
}));
|
|
|
|
vi.mock("node:crypto", () => ({
|
|
randomBytes: () => ({ toString: () => "abc123" }),
|
|
}));
|
|
|
|
vi.mock("node:fs", () => ({
|
|
existsSync: vi.fn(() => false),
|
|
createReadStream: mockCreateReadStream,
|
|
}));
|
|
|
|
vi.mock("node:readline", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof Readline>();
|
|
mockCreateInterface.mockImplementation((...args: Parameters<typeof actual.createInterface>) =>
|
|
actual.createInterface(...args),
|
|
);
|
|
return {
|
|
...actual,
|
|
createInterface: mockCreateInterface,
|
|
};
|
|
});
|
|
|
|
vi.mock("node:os", () => ({
|
|
homedir: mockHomedir,
|
|
}));
|
|
|
|
vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|
const actual = (await importOriginal()) as Record<string, unknown>;
|
|
return {
|
|
...actual,
|
|
readLastJsonlEntry: mockReadLastJsonlEntry,
|
|
isWindows: mockIsWindows,
|
|
};
|
|
});
|
|
|
|
import { Readable } from "node:stream";
|
|
import { join as pathJoin } from "node:path";
|
|
import {
|
|
create,
|
|
manifest,
|
|
default as defaultExport,
|
|
resolveCodexBinary,
|
|
_resetSessionFileCache,
|
|
} from "./index.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test helpers
|
|
// ---------------------------------------------------------------------------
|
|
function makeSession(overrides: Partial<Session> = {}): Session {
|
|
return {
|
|
id: "test-1",
|
|
projectId: "test-project",
|
|
status: "working",
|
|
activity: "active",
|
|
activitySignal: createActivitySignal("valid", {
|
|
activity: "active",
|
|
timestamp: new Date(),
|
|
source: "native",
|
|
}),
|
|
branch: "feat/test",
|
|
issueId: null,
|
|
pr: null,
|
|
prs: [],
|
|
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, 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({
|
|
stdout: ` PID TT ARGS\n${line}\n`,
|
|
stderr: "",
|
|
});
|
|
}
|
|
return Promise.reject(new Error(`Unexpected: ${cmd} ${args.join(" ")}`));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a mock file handle for `open()` that streams `content` across
|
|
* successive `read()` calls. Tracks an internal cursor so sequential reads
|
|
* advance through the buffer and eventually return `bytesRead: 0` at EOF.
|
|
* Without position tracking, readJsonlPrefixLines would loop forever on
|
|
* lines larger than its chunk size.
|
|
*/
|
|
function makeFakeFileHandle(content: string) {
|
|
const buf = Buffer.from(content, "utf-8");
|
|
let cursor = 0;
|
|
return {
|
|
read: vi
|
|
.fn()
|
|
.mockImplementation((buffer: Buffer, offset: number, length: number, _position: number) => {
|
|
if (cursor >= buf.length) {
|
|
return Promise.resolve({ bytesRead: 0, buffer });
|
|
}
|
|
const bytesToCopy = Math.min(length, buf.length - cursor);
|
|
buf.copy(buffer, offset, cursor, cursor + bytesToCopy);
|
|
cursor += bytesToCopy;
|
|
return Promise.resolve({ bytesRead: bytesToCopy, buffer });
|
|
}),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set up mockOpen so that any `open(path, "r")` call returns a fake handle
|
|
* reading `content`. This is used by sessionFileMatchesCwd.
|
|
*/
|
|
function setupMockOpen(content: string) {
|
|
mockOpen.mockResolvedValue(makeFakeFileHandle(content));
|
|
}
|
|
|
|
/**
|
|
* Create a Readable stream from a string. Used to mock createReadStream
|
|
* for the streaming JSONL parser (streamCodexSessionData).
|
|
*/
|
|
function makeContentStream(content: string): Readable {
|
|
return Readable.from(Buffer.from(content, "utf-8"));
|
|
}
|
|
|
|
/**
|
|
* Set up mockCreateReadStream to return a readable stream with the given content.
|
|
* Used by getSessionInfo/getRestoreCommand which stream files line-by-line.
|
|
*/
|
|
function setupMockStream(content: string) {
|
|
mockCreateReadStream.mockReturnValue(makeContentStream(content));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
_resetSessionFileCache();
|
|
mockHomedir.mockReturnValue("/mock/home");
|
|
// Default: open() returns a handle with empty content (no session_meta match).
|
|
// Session tests call setupMockOpen(content) to override.
|
|
mockOpen.mockResolvedValue(makeFakeFileHandle(""));
|
|
// Default: lstat rejects (no subdirectories). Session tests override as needed.
|
|
mockLstat.mockRejectedValue(new Error("ENOENT"));
|
|
// Default: createReadStream returns an empty stream. Session tests call
|
|
// setupMockStream(content) to override.
|
|
mockCreateReadStream.mockReturnValue(makeContentStream(""));
|
|
});
|
|
|
|
// =========================================================================
|
|
// 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.1",
|
|
displayName: "OpenAI Codex",
|
|
});
|
|
});
|
|
|
|
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' -c check_for_update_on_startup=false",
|
|
);
|
|
});
|
|
|
|
it("includes bypass flag when permissions=permissionless", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "permissionless" }));
|
|
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
expect(cmd).not.toContain("--ask-for-approval");
|
|
expect(cmd).not.toContain("--full-auto");
|
|
});
|
|
|
|
it("treats legacy permissions=skip as permissionless", () => {
|
|
const cmd = agent.getLaunchCommand(
|
|
makeLaunchConfig({ permissions: "skip" as unknown as AgentLaunchConfig["permissions"] }),
|
|
);
|
|
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
});
|
|
|
|
it("includes --ask-for-approval never when permissions=auto-edit", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "auto-edit" }));
|
|
expect(cmd).toContain("--ask-for-approval never");
|
|
});
|
|
|
|
it("includes --ask-for-approval untrusted when permissions=suggest", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "suggest" }));
|
|
expect(cmd).toContain("--ask-for-approval untrusted");
|
|
});
|
|
|
|
it("omits approval flags when permissions=default", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "default" }));
|
|
expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
expect(cmd).not.toContain("--ask-for-approval");
|
|
expect(cmd).not.toContain("--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: "permissionless", model: "o3", prompt: "Go" }),
|
|
);
|
|
expect(cmd).toBe(
|
|
"'codex' -c check_for_update_on_startup=false --dangerously-bypass-approvals-and-sandbox --model 'o3' -c model_reasoning_effort=high -- 'Go'",
|
|
);
|
|
});
|
|
|
|
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "it's broken" }));
|
|
if (process.platform === "win32") {
|
|
expect(cmd).toContain("-- 'it''s broken'");
|
|
} else {
|
|
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("--dangerously-bypass-approvals-and-sandbox");
|
|
expect(cmd).not.toContain("--ask-for-approval");
|
|
expect(cmd).not.toContain("--model");
|
|
expect(cmd).toContain("-c check_for_update_on_startup=false");
|
|
expect(cmd).not.toContain("model_reasoning_effort");
|
|
});
|
|
|
|
it("always includes -c check_for_update_on_startup=false", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "gpt-4o", prompt: "Fix it" }));
|
|
expect(cmd).toContain("-c check_for_update_on_startup=false");
|
|
});
|
|
|
|
// -- Reasoning effort tests --
|
|
describe("reasoning effort", () => {
|
|
it("adds model_reasoning_effort=high for o3 model", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "o3" }));
|
|
expect(cmd).toContain("-c model_reasoning_effort=high");
|
|
});
|
|
|
|
it("adds model_reasoning_effort=high for o3-mini model", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "o3-mini" }));
|
|
expect(cmd).toContain("-c model_reasoning_effort=high");
|
|
});
|
|
|
|
it("adds model_reasoning_effort=high for o4-mini model", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "o4-mini" }));
|
|
expect(cmd).toContain("-c model_reasoning_effort=high");
|
|
});
|
|
|
|
it("adds model_reasoning_effort=high for O3 (case-insensitive)", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "O3" }));
|
|
expect(cmd).toContain("-c model_reasoning_effort=high");
|
|
});
|
|
|
|
it("adds model_reasoning_effort=high for O4-MINI (case-insensitive)", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "O4-MINI" }));
|
|
expect(cmd).toContain("-c model_reasoning_effort=high");
|
|
});
|
|
|
|
it("does NOT add reasoning effort for gpt-4o model", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "gpt-4o" }));
|
|
expect(cmd).not.toContain("model_reasoning_effort");
|
|
});
|
|
|
|
it("does NOT add reasoning effort for gpt-4.1 model", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig({ model: "gpt-4.1" }));
|
|
expect(cmd).not.toContain("model_reasoning_effort");
|
|
});
|
|
|
|
it("does NOT add reasoning effort when no model specified", () => {
|
|
const cmd = agent.getLaunchCommand(makeLaunchConfig());
|
|
expect(cmd).not.toContain("model_reasoning_effort");
|
|
});
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// 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: "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();
|
|
});
|
|
|
|
it("does not set PATH (injected by session-manager)", () => {
|
|
const env = agent.getEnvironment(makeLaunchConfig());
|
|
expect(env["PATH"]).toBeUndefined();
|
|
});
|
|
|
|
it("sets CODEX_DISABLE_UPDATE_CHECK=1 to suppress interactive update prompts", () => {
|
|
const env = agent.getEnvironment(makeLaunchConfig());
|
|
expect(env["CODEX_DISABLE_UPDATE_CHECK"]).toBe("1");
|
|
});
|
|
|
|
it("does not set GH_PATH (injected by session-manager)", () => {
|
|
const env = agent.getEnvironment(makeLaunchConfig());
|
|
expect(env["GH_PATH"]).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 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);
|
|
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);
|
|
// Must NOT call external commands — could match wrong session
|
|
expect(mockExecFileAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns indeterminate on tmux command failure", async () => {
|
|
mockExecFileAsync.mockRejectedValue(new Error("tmux not running"));
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate");
|
|
});
|
|
|
|
it("returns indeterminate when ps command fails", async () => {
|
|
mockExecFileAsync.mockImplementation((cmd: string) => {
|
|
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
|
|
if (cmd === "ps") return Promise.reject(new Error("ps timed out"));
|
|
return Promise.reject(new Error("unexpected"));
|
|
});
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe("indeterminate");
|
|
});
|
|
|
|
it("returns true when PID exists but throws EPERM", async () => {
|
|
const epermErr = Object.assign(new Error("EPERM"), { code: "EPERM" });
|
|
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
|
throw epermErr;
|
|
});
|
|
expect(await agent.isProcessRunning(makeProcessHandle(789))).toBe(true);
|
|
killSpy.mockRestore();
|
|
});
|
|
|
|
it("finds codex on any pane in multi-pane session", async () => {
|
|
mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "tmux" && args[0] === "list-panes") {
|
|
return Promise.resolve({ stdout: "/dev/ttys001\n/dev/ttys002\n", stderr: "" });
|
|
}
|
|
if (cmd === "ps") {
|
|
return Promise.resolve({
|
|
stdout: " PID TT ARGS\n 100 ttys001 bash\n 200 ttys002 codex --model o3\n",
|
|
stderr: "",
|
|
});
|
|
}
|
|
return Promise.reject(new Error("unexpected"));
|
|
});
|
|
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);
|
|
});
|
|
|
|
it("returns false for tmux handle on Windows without spawning ps", async () => {
|
|
mockIsWindows.mockReturnValue(true);
|
|
mockExecFileAsync.mockRejectedValue(new Error("ps not available on Windows"));
|
|
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
|
|
expect(mockExecFileAsync).not.toHaveBeenCalledWith("ps", expect.anything(), expect.anything());
|
|
mockIsWindows.mockReturnValue(false);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// detectActivity — terminal output classification
|
|
// =========================================================================
|
|
describe("detectActivity", () => {
|
|
const agent = create();
|
|
|
|
// -- Idle states --
|
|
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 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?.state).toBe("exited");
|
|
expect(result?.timestamp).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("returns exited when process is not running", async () => {
|
|
mockTmuxWithProcess("codex", false);
|
|
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("exited");
|
|
expect(result?.timestamp).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("returns null when process probe is indeterminate", async () => {
|
|
mockExecFileAsync.mockRejectedValue(new Error("tmux not running"));
|
|
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
|
|
await expect(agent.getActivityState(session)).resolves.toBeNull();
|
|
});
|
|
|
|
it("returns null when process is running but no workspacePath", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: undefined });
|
|
expect(await agent.getActivityState(session)).toBeNull();
|
|
});
|
|
|
|
it("returns null when process is running but no session file found", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
mockReaddir.mockRejectedValue(new Error("ENOENT"));
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
expect(await agent.getActivityState(session)).toBeNull();
|
|
});
|
|
|
|
it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-activity.jsonl"]);
|
|
const modifiedAt = new Date();
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "assistant_message",
|
|
modifiedAt,
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
metadata: { codexThreadId: "thread-fast-activity" },
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
|
|
expect(result?.state).toBe("ready");
|
|
expect(mockReadLastJsonlEntry).toHaveBeenCalledWith(
|
|
pathJoin(
|
|
"/mock/home",
|
|
".codex",
|
|
"sessions",
|
|
"rollout-2026-05-22T00-00-00-thread-fast-activity.jsonl",
|
|
),
|
|
);
|
|
expect(mockOpen).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to cwd-prefix scanning when codexThreadId lookup misses", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["rollout-other-thread.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "assistant_message",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
metadata: { codexThreadId: "missing-thread" },
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
|
|
expect(result?.state).toBe("ready");
|
|
expect(mockReaddir).toHaveBeenCalledTimes(1);
|
|
expect(mockOpen).toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns active when session file was recently modified", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
// Mock readLastJsonlEntry to return a recent entry
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "tool_call",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("active");
|
|
expect(result?.timestamp).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("returns idle when session file is stale", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
const staleTime = Date.now() - 600_000;
|
|
mockStat.mockResolvedValue({ mtimeMs: staleTime, mtime: new Date(staleTime) });
|
|
// Mock readLastJsonlEntry to return a stale entry
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "assistant_message",
|
|
modifiedAt: new Date(staleTime),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("idle");
|
|
expect(result?.timestamp).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("returns waiting_input for approval_request entry type", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "approval_request",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("waiting_input");
|
|
});
|
|
|
|
it("returns blocked for error entry type", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "error",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("blocked");
|
|
});
|
|
|
|
it("returns ready for assistant_message entry type", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "assistant_message",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("ready");
|
|
});
|
|
|
|
it("returns waiting_input when payload.type is approval_request on event_msg", async () => {
|
|
// Real Codex writes {"type":"event_msg","payload":{"type":"approval_request",...}}
|
|
// Without payloadType handling, this decays to ready/idle via the event_msg case.
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "event_msg",
|
|
payloadType: "approval_request",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("waiting_input");
|
|
});
|
|
|
|
it("returns waiting_input for exec_approval_request payload type", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "event_msg",
|
|
payloadType: "exec_approval_request",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("waiting_input");
|
|
});
|
|
|
|
it("returns blocked when payload.type is error on event_msg", async () => {
|
|
// Real Codex writes {"type":"event_msg","payload":{"type":"error",...}}
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "event_msg",
|
|
payloadType: "error",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("blocked");
|
|
});
|
|
|
|
it("returns active when payload.type is task_started on a recent event_msg", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "event_msg",
|
|
payloadType: "task_started",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("active");
|
|
});
|
|
|
|
it("returns ready when payload.type is task_complete on a recent event_msg", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "event_msg",
|
|
payloadType: "task_complete",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("ready");
|
|
});
|
|
|
|
it("detects activity from payload-wrapped Codex session_meta files", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
const content = `${JSON.stringify({
|
|
type: "session_meta",
|
|
payload: {
|
|
cwd: "/workspace/test",
|
|
id: "thread-123",
|
|
base_instructions: "x".repeat(8_000),
|
|
},
|
|
})}\n`;
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "event_msg",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
expect(result?.state).toBe("ready");
|
|
});
|
|
|
|
it("handles multi-byte UTF-8 characters straddling an 8KB chunk boundary", async () => {
|
|
mockTmuxWithProcess("codex");
|
|
// Each 日 is 3 bytes. Padding with enough CJK chars to push the json
|
|
// payload past the 8192-byte chunk size, guaranteeing a multi-byte
|
|
// character will straddle a read boundary. Without StringDecoder,
|
|
// the split character decodes to U+FFFD and JSON.parse fails.
|
|
const padding = "日".repeat(3_000); // 9000 bytes of padding
|
|
const content = `${JSON.stringify({
|
|
type: "session_meta",
|
|
payload: {
|
|
cwd: "/workspace/test",
|
|
id: "thread-utf8",
|
|
base_instructions: padding,
|
|
},
|
|
})}\n`;
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
|
|
mockReadLastJsonlEntry.mockResolvedValue({
|
|
lastType: "event_msg",
|
|
modifiedAt: new Date(),
|
|
});
|
|
|
|
const session = makeSession({
|
|
runtimeHandle: makeTmuxHandle(),
|
|
workspacePath: "/workspace/test",
|
|
});
|
|
const result = await agent.getActivityState(session);
|
|
// If UTF-8 boundary handling is broken, JSON.parse fails, cwd never
|
|
// matches, no session file is selected, and state falls through to null.
|
|
expect(result?.state).toBe("ready");
|
|
});
|
|
|
|
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?.state).toBe("exited");
|
|
expect(result?.timestamp).toBeInstanceOf(Date);
|
|
killSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// getSessionInfo — Codex JSONL parsing
|
|
// =========================================================================
|
|
describe("getSessionInfo", () => {
|
|
const agent = create();
|
|
|
|
// Helper to build JSONL content from lines
|
|
function jsonl(...lines: Record<string, unknown>[]): string {
|
|
return lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
|
}
|
|
|
|
it("returns null when workspacePath is null", async () => {
|
|
expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull();
|
|
});
|
|
|
|
it("returns null when workspacePath is undefined", async () => {
|
|
expect(await agent.getSessionInfo(makeSession({ workspacePath: undefined }))).toBeNull();
|
|
});
|
|
|
|
it("returns null when ~/.codex/sessions/ directory does not exist", async () => {
|
|
mockReaddir.mockRejectedValue(new Error("ENOENT"));
|
|
expect(await agent.getSessionInfo(makeSession())).toBeNull();
|
|
});
|
|
|
|
it("returns null when sessions directory is empty", async () => {
|
|
mockReaddir.mockResolvedValue([]);
|
|
expect(await agent.getSessionInfo(makeSession())).toBeNull();
|
|
});
|
|
|
|
it("uses persisted codexThreadId filename without cwd-prefix open scans", async () => {
|
|
const sessionContent = jsonl(
|
|
{
|
|
type: "session_meta",
|
|
payload: {
|
|
id: "thread-fast-info",
|
|
},
|
|
},
|
|
{
|
|
type: "turn_context",
|
|
payload: {
|
|
model: "gpt-5.5",
|
|
},
|
|
},
|
|
);
|
|
|
|
mockReaddir.mockResolvedValue(["rollout-2026-05-22T00-00-00-thread-fast-info.jsonl"]);
|
|
setupMockStream(sessionContent);
|
|
|
|
const result = await agent.getSessionInfo(
|
|
makeSession({
|
|
workspacePath: "/workspace/test",
|
|
metadata: { codexThreadId: "thread-fast-info" },
|
|
}),
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.agentSessionId).toBe("rollout-2026-05-22T00-00-00-thread-fast-info");
|
|
expect(result!.summary).toBe("Codex session (gpt-5.5)");
|
|
expect(mockOpen).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("caches codexThreadId filename lookups by thread id", async () => {
|
|
const sessionContent = jsonl({
|
|
type: "session_meta",
|
|
payload: {
|
|
id: "thread-cached-info",
|
|
},
|
|
});
|
|
|
|
mockReaddir.mockResolvedValue(["rollout-thread-cached-info.jsonl"]);
|
|
mockCreateReadStream.mockImplementation(() => makeContentStream(sessionContent));
|
|
|
|
const first = await agent.getSessionInfo(
|
|
makeSession({
|
|
workspacePath: "/workspace/first",
|
|
metadata: { codexThreadId: "thread-cached-info" },
|
|
}),
|
|
);
|
|
const second = await agent.getSessionInfo(
|
|
makeSession({
|
|
workspacePath: "/workspace/second",
|
|
metadata: { codexThreadId: "thread-cached-info" },
|
|
}),
|
|
);
|
|
|
|
expect(first).not.toBeNull();
|
|
expect(second).not.toBeNull();
|
|
expect(mockReaddir).toHaveBeenCalledTimes(1);
|
|
expect(mockOpen).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("chooses the newest duplicate codexThreadId filename match by mtime", async () => {
|
|
const oldContent = jsonl({
|
|
type: "session_meta",
|
|
payload: { id: "thread-dupe", model: "old-model" },
|
|
});
|
|
const newContent = jsonl({
|
|
type: "session_meta",
|
|
payload: { id: "thread-dupe", model: "new-model" },
|
|
});
|
|
|
|
mockReaddir.mockResolvedValue([
|
|
"rollout-old-thread-dupe.jsonl",
|
|
"rollout-new-thread-dupe.jsonl",
|
|
]);
|
|
mockStat.mockImplementation((path: string) => {
|
|
if (path.includes("rollout-old-thread-dupe")) return Promise.resolve({ mtimeMs: 1000 });
|
|
if (path.includes("rollout-new-thread-dupe")) return Promise.resolve({ mtimeMs: 2000 });
|
|
return Promise.reject(new Error("ENOENT"));
|
|
});
|
|
mockCreateReadStream.mockImplementation((path: string) => {
|
|
if (path.includes("rollout-old-thread-dupe")) return makeContentStream(oldContent);
|
|
if (path.includes("rollout-new-thread-dupe")) return makeContentStream(newContent);
|
|
return makeContentStream("");
|
|
});
|
|
|
|
const result = await agent.getSessionInfo(
|
|
makeSession({
|
|
workspacePath: "/workspace/test",
|
|
metadata: { codexThreadId: "thread-dupe" },
|
|
}),
|
|
);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.agentSessionId).toBe("rollout-new-thread-dupe");
|
|
expect(result!.summary).toBe("Codex session (new-model)");
|
|
expect(mockOpen).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not treat infix filename matches as thread-id hits", async () => {
|
|
mockReaddir.mockResolvedValue(["rollout-thread-fast-extra.jsonl"]);
|
|
|
|
const result = await agent.getSessionInfo(
|
|
makeSession({
|
|
workspacePath: null,
|
|
metadata: { codexThreadId: "fast" },
|
|
}),
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
expect(mockOpen).not.toHaveBeenCalled();
|
|
expect(mockCreateReadStream).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns null when no session files match the workspace cwd", async () => {
|
|
mockReaddir.mockResolvedValue(["session-abc.jsonl"]);
|
|
const content = jsonl({ type: "session_meta", cwd: "/other/workspace", model: "gpt-4o" });
|
|
setupMockOpen(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
expect(
|
|
await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("falls back to cwd-prefix scanning when codexThreadId is absent", async () => {
|
|
const sessionContent = jsonl({
|
|
type: "session_meta",
|
|
cwd: "/workspace/test",
|
|
model: "gpt-5.4",
|
|
});
|
|
|
|
mockReaddir.mockResolvedValue(["rollout-cwd-fallback.jsonl"]);
|
|
setupMockOpen(sessionContent);
|
|
setupMockStream(sessionContent);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.summary).toBe("Codex session (gpt-5.4)");
|
|
expect(mockOpen).toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns session info with cost and model when matching session found", async () => {
|
|
const sessionContent = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" },
|
|
{
|
|
type: "event_msg",
|
|
msg: {
|
|
type: "token_count",
|
|
input_tokens: 1000,
|
|
output_tokens: 500,
|
|
cached_tokens: 200,
|
|
reasoning_tokens: 100,
|
|
},
|
|
},
|
|
{
|
|
type: "event_msg",
|
|
msg: {
|
|
type: "token_count",
|
|
input_tokens: 2000,
|
|
output_tokens: 300,
|
|
cached_tokens: 0,
|
|
reasoning_tokens: 0,
|
|
},
|
|
},
|
|
);
|
|
|
|
mockReaddir.mockResolvedValue(["session-123.jsonl"]);
|
|
setupMockOpen(sessionContent);
|
|
setupMockStream(sessionContent);
|
|
mockReadFile.mockResolvedValue(sessionContent);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.agentSessionId).toBe("session-123");
|
|
expect(result!.summary).toBe("Codex session (o3-mini)");
|
|
expect(result!.summaryIsFallback).toBe(true);
|
|
expect(result!.cost).toBeDefined();
|
|
// cached tokens count toward effective input spend
|
|
// input: 1000 + 2000 + 200 = 3200
|
|
// output: 500 + 300 = 800
|
|
expect(result!.cost!.inputTokens).toBe(3200);
|
|
expect(result!.cost!.outputTokens).toBe(800);
|
|
expect(result!.cost!.estimatedCostUsd).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("parses payload-wrapped Codex session files", async () => {
|
|
const sessionContent = jsonl(
|
|
{
|
|
type: "session_meta",
|
|
payload: {
|
|
cwd: "/workspace/test",
|
|
id: "thread-payload-123",
|
|
model_provider: "openai",
|
|
},
|
|
},
|
|
{
|
|
type: "turn_context",
|
|
payload: {
|
|
model: "gpt-5.3-codex",
|
|
cwd: "/workspace/test",
|
|
},
|
|
},
|
|
{
|
|
type: "event_msg",
|
|
payload: {
|
|
type: "token_count",
|
|
info: {
|
|
total_token_usage: {
|
|
input_tokens: 3000,
|
|
output_tokens: 800,
|
|
cached_input_tokens: 200,
|
|
reasoning_output_tokens: 100,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
mockReaddir.mockResolvedValue(["rollout-abc.jsonl"]);
|
|
setupMockOpen(sessionContent);
|
|
setupMockStream(sessionContent);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.agentSessionId).toBe("rollout-abc");
|
|
expect(result!.summary).toBe("Codex session (gpt-5.3-codex)");
|
|
expect(result!.cost).toBeDefined();
|
|
expect(result!.cost!.inputTokens).toBe(3000);
|
|
expect(result!.cost!.outputTokens).toBe(800);
|
|
});
|
|
|
|
it("does not treat model_provider as the session model", async () => {
|
|
const sessionContent = jsonl({
|
|
type: "session_meta",
|
|
payload: {
|
|
cwd: "/workspace/test",
|
|
id: "thread-payload-123",
|
|
model_provider: "openai",
|
|
},
|
|
});
|
|
|
|
mockReaddir.mockResolvedValue(["rollout-abc.jsonl"]);
|
|
setupMockOpen(sessionContent);
|
|
setupMockStream(sessionContent);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.summary).toBeNull();
|
|
});
|
|
|
|
it("picks the most recently modified matching session file", async () => {
|
|
const oldContent = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
|
const newContent = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "o3" });
|
|
|
|
mockReaddir.mockResolvedValue(["old-session.jsonl", "new-session.jsonl"]);
|
|
mockOpen.mockImplementation(async (path: string) => {
|
|
if (path.includes("old-session")) return makeFakeFileHandle(oldContent);
|
|
if (path.includes("new-session")) return makeFakeFileHandle(newContent);
|
|
throw new Error("ENOENT");
|
|
});
|
|
mockReadFile.mockImplementation((path: string) => {
|
|
if (path.includes("old-session")) return Promise.resolve(oldContent);
|
|
if (path.includes("new-session")) return Promise.resolve(newContent);
|
|
return Promise.reject(new Error("ENOENT"));
|
|
});
|
|
mockCreateReadStream.mockImplementation((path: string) => {
|
|
if (path.includes("old-session")) return makeContentStream(oldContent);
|
|
if (path.includes("new-session")) return makeContentStream(newContent);
|
|
return makeContentStream("");
|
|
});
|
|
mockStat.mockImplementation((path: string) => {
|
|
if (path.includes("old-session")) return Promise.resolve({ mtimeMs: 1000 });
|
|
if (path.includes("new-session")) return Promise.resolve({ mtimeMs: 2000 });
|
|
return Promise.reject(new Error("ENOENT"));
|
|
});
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.agentSessionId).toBe("new-session");
|
|
expect(result!.summary).toBe("Codex session (o3)");
|
|
});
|
|
|
|
it("handles corrupt/malformed JSONL lines gracefully", async () => {
|
|
const content =
|
|
'{"type":"session_meta","cwd":"/workspace/test","model":"gpt-4o"}\n' +
|
|
"not valid json\n" +
|
|
'{"type":"event_msg","msg":{"type":"token_count","input_tokens":500,"output_tokens":200}}\n';
|
|
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.cost!.inputTokens).toBe(500);
|
|
expect(result!.cost!.outputTokens).toBe(200);
|
|
});
|
|
|
|
it("returns null summary when no model in session_meta", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test" },
|
|
{ type: "event_msg", msg: { type: "token_count", input_tokens: 100, output_tokens: 50 } },
|
|
);
|
|
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.summary).toBeNull();
|
|
// Verify cost was actually parsed from the stream (not just defaulting to undefined)
|
|
expect(result!.cost).toBeDefined();
|
|
expect(result!.cost!.inputTokens).toBe(100);
|
|
expect(result!.cost!.outputTokens).toBe(50);
|
|
});
|
|
|
|
it("returns undefined cost when no token_count events", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
|
{ type: "event_msg", msg: { type: "other_event" } },
|
|
);
|
|
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(result!.cost).toBeUndefined();
|
|
// Verify model was actually parsed from the stream (not just defaulting to null)
|
|
expect(result!.summary).toContain("gpt-4o");
|
|
});
|
|
|
|
it("handles unreadable session files gracefully", async () => {
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
// open() finds matching session_meta, but readFile fails for full parse
|
|
setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" }));
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
mockReadFile.mockRejectedValue(new Error("EACCES"));
|
|
mockCreateReadStream.mockImplementation(() => {
|
|
throw new Error("EACCES");
|
|
});
|
|
|
|
expect(
|
|
await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("closes readline and destroys the stream when JSONL streaming is interrupted", async () => {
|
|
const content = jsonl({ type: "session_meta", cwd: "/workspace/test" });
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const stream = makeContentStream(content);
|
|
const destroySpy = vi.spyOn(stream, "destroy");
|
|
const closeSpy = vi.fn();
|
|
mockCreateReadStream.mockReturnValue(stream);
|
|
mockCreateInterface.mockImplementationOnce(() => ({
|
|
close: closeSpy,
|
|
async *[Symbol.asyncIterator]() {
|
|
yield JSON.stringify({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
|
throw new Error("aborted");
|
|
},
|
|
}));
|
|
|
|
expect(
|
|
await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" })),
|
|
).toBeNull();
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
expect(destroySpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("skips session files when stat throws", async () => {
|
|
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockRejectedValue(new Error("EACCES"));
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
// stat failed so no bestMatch can be established
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when session JSONL has only empty/malformed lines", async () => {
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
// open() finds matching session_meta for cwd check
|
|
setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" }));
|
|
// readFile (full parse) returns only garbage
|
|
mockReadFile.mockResolvedValue("not json\n\n \n");
|
|
setupMockStream("not json\n\n \n");
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
// With streaming parser, garbage lines are skipped gracefully and a result
|
|
// is returned with null summary and undefined cost (no valid data extracted).
|
|
expect(result).not.toBeNull();
|
|
expect(result!.summary).toBeNull();
|
|
expect(result!.cost).toBeUndefined();
|
|
});
|
|
|
|
it("finds session files in date-sharded subdirectories (YYYY/MM/DD)", async () => {
|
|
// Simulate ~/.codex/sessions/2026/02/24/rollout-abc.jsonl
|
|
mockReaddir.mockImplementation((dir: string) => {
|
|
if (dir.endsWith("sessions")) return Promise.resolve(["2026"]);
|
|
if (dir.endsWith("2026")) return Promise.resolve(["02"]);
|
|
if (dir.endsWith("02")) return Promise.resolve(["24"]);
|
|
if (dir.endsWith("24")) return Promise.resolve(["rollout-abc.jsonl"]);
|
|
return Promise.resolve([]);
|
|
});
|
|
// lstat is used by collectJsonlFiles to check subdirectories (avoids symlink cycles)
|
|
mockLstat.mockResolvedValue({ isDirectory: () => true });
|
|
// stat is used by findCodexSessionFile to get mtimeMs of matching JSONL files
|
|
mockStat.mockResolvedValue({ mtimeMs: 2000 });
|
|
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" });
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
expect(result).not.toBeNull();
|
|
expect(result!.agentSessionId).toBe("rollout-abc");
|
|
expect(result!.summary).toBe("Codex session (o3-mini)");
|
|
});
|
|
|
|
it("ignores non-JSONL files in sessions directory", async () => {
|
|
mockReaddir.mockResolvedValue(["notes.txt", "config.json", "sess.jsonl"]);
|
|
const content = jsonl({ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" });
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
// Non-JSONL entries trigger lstat to check isDirectory()
|
|
mockLstat.mockResolvedValue({ isDirectory: () => false });
|
|
// stat is used to get mtimeMs for matching JSONL files
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const result = await agent.getSessionInfo(makeSession({ workspacePath: "/workspace/test" }));
|
|
expect(result).not.toBeNull();
|
|
// With streaming parser, readFile is no longer used for full parse;
|
|
// cwd check uses open(), data extraction uses createReadStream.
|
|
const readFileCalls = mockReadFile.mock.calls.filter(
|
|
(call: string[]) => typeof call[0] === "string" && call[0].includes("sessions/"),
|
|
);
|
|
expect(readFileCalls.length).toBe(0); // streaming replaces readFile for full parse
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// getRestoreCommand — conversation resume
|
|
// =========================================================================
|
|
describe("getRestoreCommand", () => {
|
|
const agent = create();
|
|
|
|
function jsonl(...lines: Record<string, unknown>[]): string {
|
|
return lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
|
}
|
|
|
|
function makeProjectConfig(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
name: "test-project",
|
|
repo: "owner/repo",
|
|
path: "/workspace/repo",
|
|
defaultBranch: "main",
|
|
sessionPrefix: "test",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("returns null when workspacePath is null", async () => {
|
|
const session = makeSession({ workspacePath: null });
|
|
expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull();
|
|
});
|
|
|
|
it("returns null when workspacePath is undefined", async () => {
|
|
const session = makeSession({ workspacePath: undefined });
|
|
expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull();
|
|
});
|
|
|
|
it("returns null when no matching session file found", async () => {
|
|
mockReaddir.mockRejectedValue(new Error("ENOENT"));
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull();
|
|
});
|
|
|
|
it("returns null when session has no threadId", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
|
{ role: "user", content: "Some prompt" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
// Native resume requires a threadId
|
|
expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull();
|
|
});
|
|
|
|
it("builds native resume command with codex resume <threadId>", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
|
{ threadId: "thread-abc-123" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig());
|
|
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd).toContain("'codex' resume");
|
|
expect(cmd).toContain("-c check_for_update_on_startup=false");
|
|
expect(cmd).toContain("thread-abc-123");
|
|
});
|
|
|
|
it("uses persisted Codex thread ID without scanning session files", async () => {
|
|
const session = makeSession({
|
|
workspacePath: "/workspace/test",
|
|
metadata: { codexThreadId: "persisted-thread", codexModel: "gpt-5.3-codex" },
|
|
});
|
|
|
|
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig());
|
|
|
|
expect(cmd).toContain("'codex' resume");
|
|
expect(cmd).toContain("--model 'gpt-5.3-codex'");
|
|
expect(cmd).toContain("persisted-thread");
|
|
expect(mockReaddir).not.toHaveBeenCalled();
|
|
expect(mockOpen).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("builds native resume command from payload-wrapped Codex session id", async () => {
|
|
const content = jsonl(
|
|
{
|
|
type: "session_meta",
|
|
payload: {
|
|
cwd: "/workspace/test",
|
|
id: "thread-payload-999",
|
|
model_provider: "openai",
|
|
},
|
|
},
|
|
{
|
|
type: "turn_context",
|
|
payload: {
|
|
model: "gpt-5.3-codex",
|
|
},
|
|
},
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig());
|
|
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd).toContain("'codex' resume");
|
|
expect(cmd).toContain("thread-payload-999");
|
|
});
|
|
|
|
it("does not append --model from model_provider-only payload data", async () => {
|
|
const content = jsonl({
|
|
type: "session_meta",
|
|
payload: {
|
|
cwd: "/workspace/test",
|
|
id: "thread-payload-999",
|
|
model_provider: "openai",
|
|
},
|
|
});
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig());
|
|
|
|
expect(cmd).not.toBeNull();
|
|
expect(cmd).not.toContain("--model 'openai'");
|
|
});
|
|
|
|
it("includes bypass flag when project config permissions=permissionless", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
|
{ threadId: "thread-1" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({
|
|
workspacePath: "/workspace/test",
|
|
metadata: { role: "orchestrator" },
|
|
});
|
|
const cmd = await agent.getRestoreCommand!(
|
|
session,
|
|
makeProjectConfig({
|
|
agentConfig: { permissions: "permissionless" },
|
|
}),
|
|
);
|
|
|
|
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
expect(cmd).not.toContain("--ask-for-approval");
|
|
});
|
|
|
|
it("treats legacy project config permissions=skip as permissionless", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
|
{ threadId: "thread-1" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({
|
|
workspacePath: "/workspace/test",
|
|
metadata: { role: "orchestrator" },
|
|
});
|
|
const cmd = await agent.getRestoreCommand!(
|
|
session,
|
|
makeProjectConfig({
|
|
agentConfig: { permissions: "skip" as unknown as AgentSpecificConfig["permissions"] },
|
|
}),
|
|
);
|
|
|
|
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
});
|
|
|
|
it("uses dangerous bypass for worker restore permissionless mode", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
|
{ threadId: "thread-1" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test", metadata: { role: "worker" } });
|
|
const cmd = await agent.getRestoreCommand!(
|
|
session,
|
|
makeProjectConfig({
|
|
agentConfig: { permissions: "permissionless" },
|
|
}),
|
|
);
|
|
|
|
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
expect(cmd).not.toContain("--ask-for-approval");
|
|
});
|
|
|
|
it("keeps auto-edit restore policy at ask-for-approval never", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
|
{ threadId: "thread-1" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
const cmd = await agent.getRestoreCommand!(
|
|
session,
|
|
makeProjectConfig({
|
|
agentConfig: { permissions: "auto-edit" },
|
|
}),
|
|
);
|
|
|
|
expect(cmd).toContain("--ask-for-approval never");
|
|
expect(cmd).not.toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
});
|
|
|
|
it("includes --ask-for-approval untrusted from project config", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
|
{ threadId: "thread-1" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
const cmd = await agent.getRestoreCommand!(
|
|
session,
|
|
makeProjectConfig({
|
|
agentConfig: { permissions: "suggest" },
|
|
}),
|
|
);
|
|
|
|
expect(cmd).toContain("--ask-for-approval untrusted");
|
|
});
|
|
|
|
it("places flags before positional threadId in resume command", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "o3-mini" },
|
|
{ threadId: "thread-order-test" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
const cmd = await agent.getRestoreCommand!(
|
|
session,
|
|
makeProjectConfig({
|
|
agentConfig: { permissions: "auto-edit", model: "o3-mini" },
|
|
}),
|
|
);
|
|
|
|
expect(cmd).not.toBeNull();
|
|
// threadId should come after all flags
|
|
const threadIdIdx = cmd!.indexOf("thread-order-test");
|
|
const flagIdx = cmd!.indexOf("--ask-for-approval");
|
|
const modelIdx = cmd!.indexOf("--model");
|
|
expect(flagIdx).toBeLessThan(threadIdIdx);
|
|
expect(modelIdx).toBeLessThan(threadIdIdx);
|
|
});
|
|
|
|
it("includes model from project config (overrides session model)", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "gpt-4o" },
|
|
{ threadId: "thread-1" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
const cmd = await agent.getRestoreCommand!(
|
|
session,
|
|
makeProjectConfig({
|
|
agentConfig: { model: "o3-mini" },
|
|
}),
|
|
);
|
|
|
|
expect(cmd).toContain("--model 'o3-mini'");
|
|
expect(cmd).toContain("-c model_reasoning_effort=high");
|
|
});
|
|
|
|
it("falls back to session model when project config has no model", async () => {
|
|
const content = jsonl(
|
|
{ type: "session_meta", cwd: "/workspace/test", model: "o4-mini" },
|
|
{ threadId: "thread-1" },
|
|
);
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
setupMockOpen(content);
|
|
setupMockStream(content);
|
|
mockReadFile.mockResolvedValue(content);
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
const cmd = await agent.getRestoreCommand!(session, makeProjectConfig());
|
|
|
|
expect(cmd).toContain("--model 'o4-mini'");
|
|
expect(cmd).toContain("-c model_reasoning_effort=high");
|
|
});
|
|
|
|
it("handles unreadable session files gracefully", async () => {
|
|
mockReaddir.mockResolvedValue(["sess.jsonl"]);
|
|
// open() finds matching session_meta for cwd check
|
|
setupMockOpen(jsonl({ type: "session_meta", cwd: "/workspace/test" }));
|
|
// readFile (full parse) fails
|
|
mockReadFile.mockRejectedValue(new Error("EACCES"));
|
|
mockCreateReadStream.mockImplementation(() => {
|
|
throw new Error("EACCES");
|
|
});
|
|
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
|
|
const session = makeSession({ workspacePath: "/workspace/test" });
|
|
expect(await agent.getRestoreCommand!(session, makeProjectConfig())).toBeNull();
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// resolveCodexBinary
|
|
// =========================================================================
|
|
describe("resolveCodexBinary", () => {
|
|
it("returns path from `which` when codex is found", async () => {
|
|
mockExecFileAsync.mockResolvedValue({ stdout: "/usr/local/bin/codex\n", stderr: "" });
|
|
const result = await resolveCodexBinary();
|
|
expect(result).toBe("/usr/local/bin/codex");
|
|
expect(mockExecFileAsync).toHaveBeenCalledWith("which", ["codex"], { timeout: 10_000 });
|
|
});
|
|
|
|
it("falls back to common locations when `which` fails", async () => {
|
|
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
|
mockStat.mockImplementation((path: string) => {
|
|
if (path === "/usr/local/bin/codex") {
|
|
return Promise.resolve({ mtimeMs: 1000 });
|
|
}
|
|
return Promise.reject(new Error("ENOENT"));
|
|
});
|
|
|
|
const result = await resolveCodexBinary();
|
|
expect(result).toBe("/usr/local/bin/codex");
|
|
});
|
|
|
|
it("checks /opt/homebrew/bin/codex as fallback", async () => {
|
|
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
|
mockStat.mockImplementation((path: string) => {
|
|
if (path === "/opt/homebrew/bin/codex") {
|
|
return Promise.resolve({ mtimeMs: 1000 });
|
|
}
|
|
return Promise.reject(new Error("ENOENT"));
|
|
});
|
|
|
|
const result = await resolveCodexBinary();
|
|
expect(result).toBe("/opt/homebrew/bin/codex");
|
|
});
|
|
|
|
it("checks ~/.cargo/bin/codex as fallback (Rust-based codex)", async () => {
|
|
const expectedPath = pathJoin("/mock/home", ".cargo", "bin", "codex");
|
|
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
|
mockStat.mockImplementation((p: string) => {
|
|
if (p === expectedPath) {
|
|
return Promise.resolve({ mtimeMs: 1000 });
|
|
}
|
|
return Promise.reject(new Error("ENOENT"));
|
|
});
|
|
|
|
const result = await resolveCodexBinary();
|
|
expect(result).toBe(expectedPath);
|
|
});
|
|
|
|
it("checks ~/.npm/bin/codex as fallback", async () => {
|
|
const expectedPath = pathJoin("/mock/home", ".npm", "bin", "codex");
|
|
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
|
mockStat.mockImplementation((p: string) => {
|
|
if (p === expectedPath) {
|
|
return Promise.resolve({ mtimeMs: 1000 });
|
|
}
|
|
return Promise.reject(new Error("ENOENT"));
|
|
});
|
|
|
|
const result = await resolveCodexBinary();
|
|
expect(result).toBe(expectedPath);
|
|
});
|
|
|
|
it("returns 'codex' when not found anywhere", async () => {
|
|
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
|
mockStat.mockRejectedValue(new Error("ENOENT"));
|
|
|
|
const result = await resolveCodexBinary();
|
|
expect(result).toBe("codex");
|
|
});
|
|
|
|
it("returns 'codex' when `which` returns empty stdout", async () => {
|
|
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" });
|
|
mockStat.mockRejectedValue(new Error("ENOENT"));
|
|
|
|
const result = await resolveCodexBinary();
|
|
expect(result).toBe("codex");
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// postLaunchSetup — binary resolution
|
|
// =========================================================================
|
|
describe("postLaunchSetup", () => {
|
|
it("has postLaunchSetup method", () => {
|
|
const agent = create();
|
|
expect(typeof agent.postLaunchSetup).toBe("function");
|
|
});
|
|
|
|
it("runs setup when session has workspacePath", async () => {
|
|
const agent = create();
|
|
// which fails, stat fails → resolves to "codex"
|
|
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
|
mockStat.mockRejectedValue(new Error("ENOENT"));
|
|
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
|
// Should not throw — binary resolution runs even if it falls back to "codex"
|
|
await expect(
|
|
agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" })),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("returns early when session has no workspacePath", async () => {
|
|
const agent = create();
|
|
mockExecFileAsync.mockRejectedValue(new Error("not found"));
|
|
mockStat.mockRejectedValue(new Error("ENOENT"));
|
|
await agent.postLaunchSetup!(makeSession({ workspacePath: undefined }));
|
|
expect(mockMkdir).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves binary and uses it in getLaunchCommand after postLaunchSetup", async () => {
|
|
const agent = create();
|
|
mockExecFileAsync.mockResolvedValue({ stdout: "/opt/bin/codex\n", stderr: "" });
|
|
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
|
|
|
// Before postLaunchSetup, binary is "codex"
|
|
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe(
|
|
"'codex' -c check_for_update_on_startup=false",
|
|
);
|
|
|
|
// After postLaunchSetup resolves the binary
|
|
await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" }));
|
|
|
|
// Now getLaunchCommand should use the resolved binary
|
|
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe(
|
|
"'/opt/bin/codex' -c check_for_update_on_startup=false",
|
|
);
|
|
});
|
|
});
|
|
|
|
// =========================================================================
|
|
// setupWorkspaceHooks — file writing behavior
|
|
// =========================================================================
|
|
describe("setupWorkspaceHooks", () => {
|
|
const agent = create();
|
|
|
|
it("has setupWorkspaceHooks method", () => {
|
|
expect(typeof agent.setupWorkspaceHooks).toBe("function");
|
|
});
|
|
|
|
it("is a no-op (PATH wrappers are installed by session-manager)", async () => {
|
|
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
|
await agent.setupWorkspaceHooks!("/workspace/test", {
|
|
dataDir: "/data",
|
|
sessionId: "sess-1",
|
|
});
|
|
// Plugin no longer writes wrappers — session-manager handles it.
|
|
// mkdir/writeFile/rename should not be called by the plugin.
|
|
expect(mockMkdir).not.toHaveBeenCalled();
|
|
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
expect(mockRename).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// (Legacy wrapper-write tests removed: the plugin no longer installs wrappers
|
|
// or writes ao-metadata-helper.sh / gh / git / .ao-version — session-manager
|
|
// owns that path now. See main's test refresh in #1487.)
|
|
|
|
// =========================================================================
|
|
// Shell wrapper content verification
|
|
// =========================================================================
|
|
// Skip on Windows: these tests verify Unix shell-script wrapper contents
|
|
// (ao-metadata-helper.sh / gh / git wrappers) which don't apply to PowerShell.
|
|
describe.skipIf(process.platform === "win32")("shell wrapper content", () => {
|
|
beforeEach(() => {
|
|
// Force wrapper installation by making version marker miss
|
|
mockReadFile.mockRejectedValue(new Error("ENOENT"));
|
|
});
|
|
|
|
async function getWrapperContent(name: string): Promise<string> {
|
|
// Wrappers are now installed by session-manager via setupPathWrapperWorkspace.
|
|
// Import and call it directly to test wrapper content.
|
|
const { setupPathWrapperWorkspace } = await import("@aoagents/ao-core");
|
|
await setupPathWrapperWorkspace("/workspace/test");
|
|
|
|
// With atomic writes, content is written to a .tmp. file
|
|
const call = mockWriteFile.mock.calls.find(
|
|
(c: unknown[]) => typeof c[0] === "string" && (c[0] as string).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", async () => {
|
|
const content = await getWrapperContent("gh");
|
|
expect(content).toContain('case "$1/$2" in');
|
|
expect(content).toContain("pr/create)");
|
|
});
|
|
|
|
it("passes through non-PR commands to real gh", async () => {
|
|
const content = await getWrapperContent("gh");
|
|
expect(content).toContain('"$real_gh" "$@"');
|
|
});
|
|
|
|
it("prefers GH_PATH when provided and executable", async () => {
|
|
const content = await getWrapperContent("gh");
|
|
expect(content).toContain("GH_PATH");
|
|
expect(content).toContain('-x "$GH_PATH"');
|
|
expect(content).toContain('real_gh="$GH_PATH"');
|
|
});
|
|
|
|
it("guards against recursive GH_PATH pointing to ao wrapper dir", async () => {
|
|
const content = await getWrapperContent("gh");
|
|
expect(content).toContain('if [[ "$gh_dir" != "$ao_bin_dir" ]]');
|
|
});
|
|
|
|
it("extracts PR URL from gh pr create output", async () => {
|
|
const content = await getWrapperContent("gh");
|
|
expect(content).toContain("grep -Eo 'https?://[^/]+/[^/]+/[^/]+/pull/[0-9]+'");
|
|
expect(content).toContain("update_ao_metadata pr");
|
|
});
|
|
|
|
it("records agent-reported PR metadata on gh pr create", async () => {
|
|
const content = await getWrapperContent("gh");
|
|
expect(content).toContain("update_ao_metadata agentReportedState");
|
|
expect(content).toContain("update_ao_metadata agentReportedPrUrl");
|
|
expect(content).toContain("update_ao_metadata agentReportedPrIsDraft");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
});
|