import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; import type { CodexComputerUseStatus } from "./app-server/computer-use.js"; import type { CodexAppServerStartOptions } from "./app-server/config.js"; import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js"; import type { CodexCommandDeps } from "./command-handlers.js"; import { handleCodexCommand } from "./commands.js"; let tempDir: string; function createContext( args: string, sessionFile?: string, overrides: Partial = {}, ): PluginCommandContext { return { channel: "test", isAuthorizedSender: true, args, commandBody: `/codex ${args}`, config: {}, sessionFile, requestConversationBinding: async () => ({ status: "error", message: "unused" }), detachConversationBinding: async () => ({ removed: false }), getCurrentConversationBinding: async () => null, ...overrides, }; } function createDeps(overrides: Partial = {}): Partial { return { codexControlRequest: vi.fn(), listCodexAppServerModels: vi.fn(), readCodexStatusProbes: vi.fn(), requestOptions: vi.fn((_pluginConfig: unknown, limit: number) => ({ limit, timeoutMs: 1000, startOptions: { transport: "stdio", command: "codex", args: ["app-server", "--listen", "stdio://"], headers: {}, } satisfies CodexAppServerStartOptions, })), safeCodexControlRequest: vi.fn(), ...overrides, }; } describe("codex command", () => { beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-command-")); }); afterEach(async () => { resetSharedCodexAppServerClientForTests(); await fs.rm(tempDir, { recursive: true, force: true }); }); it("attaches the current session to an existing Codex thread", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const requests: Array<{ method: string; params: unknown }> = []; const deps = createDeps({ codexControlRequest: vi.fn( async (_pluginConfig: unknown, method: string, requestParams: unknown) => { requests.push({ method, params: requestParams }); return { thread: { id: "thread-123", cwd: "/repo" }, model: "gpt-5.4", modelProvider: "openai", }; }, ), }); await expect( handleCodexCommand(createContext("resume thread-123", sessionFile), { deps }), ).resolves.toEqual({ text: "Attached this OpenClaw session to Codex thread thread-123.", }); expect(requests).toEqual([ { method: "thread/resume", params: { threadId: "thread-123", persistExtendedHistory: true }, }, ]); await expect(fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8")).resolves.toContain( '"threadId": "thread-123"', ); }); it("shows model ids from Codex app-server", async () => { const deps = createDeps({ listCodexAppServerModels: vi.fn(async () => ({ models: [ { id: "gpt-5.4", model: "gpt-5.4", inputModalities: ["text"], supportedReasoningEfforts: ["medium"], }, ], })), }); await expect(handleCodexCommand(createContext("models"), { deps })).resolves.toEqual({ text: "Codex models:\n- gpt-5.4", }); }); it("shows when Codex app-server model output is truncated", async () => { const deps = createDeps({ listCodexAppServerModels: vi.fn(async () => ({ models: [ { id: "gpt-5.4", model: "gpt-5.4", inputModalities: ["text"], supportedReasoningEfforts: ["medium"], }, ], nextCursor: "page-2", truncated: true, })), }); await expect(handleCodexCommand(createContext("models"), { deps })).resolves.toEqual({ text: "Codex models:\n- gpt-5.4\n- More models available; output truncated.", }); }); it("reports status unavailable when every Codex probe fails", async () => { const offline = { ok: false as const, error: "offline" }; const deps = createDeps({ readCodexStatusProbes: vi.fn(async () => ({ models: offline, account: offline, limits: offline, mcps: offline, skills: offline, })), }); await expect(handleCodexCommand(createContext("status"), { deps })).resolves.toEqual({ text: [ "Codex app-server: unavailable", "Models: offline", "Account: offline", "Rate limits: offline", "MCP servers: offline", "Skills: offline", ].join("\n"), }); }); it("formats generated account/read responses", async () => { const safeCodexControlRequest = vi .fn() .mockResolvedValueOnce({ ok: true, value: { account: { type: "chatgpt", email: "codex@example.com", planType: "pro" }, requiresOpenaiAuth: false, }, }) .mockResolvedValueOnce({ ok: true, value: { data: [{ name: "primary" }] } }); await expect( handleCodexCommand(createContext("account"), { deps: createDeps({ safeCodexControlRequest }), }), ).resolves.toEqual({ text: ["Account: codex@example.com", "Rate limits: 1"].join("\n"), }); expect(safeCodexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.account, { refreshToken: false, }); }); it("formats generated Amazon Bedrock account responses", async () => { const safeCodexControlRequest = vi .fn() .mockResolvedValueOnce({ ok: true, value: { account: { type: "amazonBedrock" }, requiresOpenaiAuth: false }, }) .mockResolvedValueOnce({ ok: true, value: [] }); await expect( handleCodexCommand(createContext("account"), { deps: createDeps({ safeCodexControlRequest }), }), ).resolves.toEqual({ text: ["Account: Amazon Bedrock", "Rate limits: none returned"].join("\n"), }); }); it("starts compaction for the attached Codex thread", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( `${sessionFile}.codex-app-server.json`, JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }), ); const codexControlRequest = vi.fn(async () => ({})); const deps = createDeps({ codexControlRequest, }); await expect( handleCodexCommand(createContext("compact", sessionFile), { deps }), ).resolves.toEqual({ text: "Started Codex compaction for thread thread-123.", }); expect(codexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.compact, { threadId: "thread-123", }); }); it("starts review with the generated app-server target shape", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( `${sessionFile}.codex-app-server.json`, JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }), ); const codexControlRequest = vi.fn(async () => ({})); await expect( handleCodexCommand(createContext("review", sessionFile), { deps: createDeps({ codexControlRequest }), }), ).resolves.toEqual({ text: "Started Codex review for thread thread-123.", }); expect(codexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.review, { threadId: "thread-123", target: { type: "uncommittedChanges" }, }); }); it("checks Codex Computer Use setup", async () => { const readCodexComputerUseStatus = vi.fn(async () => computerUseReadyStatus()); await expect( handleCodexCommand(createContext("computer-use status"), { deps: createDeps({ readCodexComputerUseStatus }), }), ).resolves.toEqual({ text: [ "Computer Use: ready", "Plugin: computer-use (installed)", "MCP server: computer-use (1 tools)", "Marketplace: desktop-tools", "Tools: list_apps", "Computer Use is ready.", ].join("\n"), }); expect(readCodexComputerUseStatus).toHaveBeenCalledWith({ pluginConfig: undefined, forceEnable: false, }); }); it("formats disabled installed Codex Computer Use plugins", async () => { const readCodexComputerUseStatus = vi.fn(async () => ({ ...computerUseReadyStatus(), ready: false, reason: "plugin_disabled" as const, pluginEnabled: false, mcpServerAvailable: false, tools: [], message: "Computer Use is installed, but the computer-use plugin is disabled. Run /codex computer-use install or enable computerUse.autoInstall to re-enable it.", })); await expect( handleCodexCommand(createContext("computer-use status"), { deps: createDeps({ readCodexComputerUseStatus }), }), ).resolves.toEqual({ text: expect.stringContaining("Plugin: computer-use (installed, disabled)"), }); }); it("installs Codex Computer Use from command overrides", async () => { const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus()); await expect( handleCodexCommand( createContext( "computer-use install --source github:example/desktop-tools --marketplace desktop-tools", ), { deps: createDeps({ installCodexComputerUse }), }, ), ).resolves.toEqual({ text: expect.stringContaining("Computer Use: ready"), }); expect(installCodexComputerUse).toHaveBeenCalledWith({ pluginConfig: undefined, forceEnable: true, overrides: { marketplaceSource: "github:example/desktop-tools", marketplaceName: "desktop-tools", }, }); }); it("shows help when Computer Use option values are missing", async () => { const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus()); await expect( handleCodexCommand(createContext("computer-use install --source"), { deps: createDeps({ installCodexComputerUse }), }), ).resolves.toEqual({ text: expect.stringContaining("Usage: /codex computer-use"), }); expect(installCodexComputerUse).not.toHaveBeenCalled(); }); it("explains compaction when no Codex thread is attached", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await expect( handleCodexCommand(createContext("compact", sessionFile), { deps: createDeps() }), ).resolves.toEqual({ text: "No Codex thread is attached to this OpenClaw session yet.", }); }); it("passes filters to Codex thread listing", async () => { const codexControlRequest = vi.fn(async () => ({ data: [{ id: "thread-123", title: "Fix the thing", model: "gpt-5.4", cwd: "/repo" }], })); const deps = createDeps({ codexControlRequest, }); await expect(handleCodexCommand(createContext("threads fix"), { deps })).resolves.toEqual({ text: [ "Codex threads:", "- thread-123 - Fix the thing (gpt-5.4, /repo)", " Resume: /codex resume thread-123", ].join("\n"), }); expect(codexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.listThreads, { limit: 10, searchTerm: "fix", }); }); it("binds the current conversation to a Codex app-server thread", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( `${sessionFile}.codex-app-server.json`, JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo" }), ); const startCodexConversationThread = vi.fn(async () => ({ kind: "codex-app-server-session" as const, version: 1 as const, sessionFile, workspaceDir: "/repo", })); const requestConversationBinding = vi.fn(async () => ({ status: "bound" as const, binding: { bindingId: "binding-1", pluginId: "codex", pluginRoot: "/plugin", channel: "test", accountId: "default", conversationId: "conversation", boundAt: 1, }, })); await expect( handleCodexCommand( createContext( "bind thread-123 --cwd /repo --model gpt-5.4 --provider openai", sessionFile, { requestConversationBinding, }, ), { deps: createDeps({ startCodexConversationThread, resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), }), }, ), ).resolves.toEqual({ text: "Bound this conversation to Codex thread thread-123 in /repo.", }); expect(startCodexConversationThread).toHaveBeenCalledWith({ pluginConfig: undefined, sessionFile, workspaceDir: "/repo", threadId: "thread-123", model: "gpt-5.4", modelProvider: "openai", }); expect(requestConversationBinding).toHaveBeenCalledWith({ summary: "Codex app-server thread thread-123 in /repo", detachHint: "/codex detach", data: { kind: "codex-app-server-session", version: 1, sessionFile, workspaceDir: "/repo", }, }); }); it("returns the binding approval reply when conversation bind needs approval", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const reply = { text: "Approve this?" }; await expect( handleCodexCommand( createContext("bind", sessionFile, { requestConversationBinding: async () => ({ status: "pending", approvalId: "approval-1", reply, }), }), { deps: createDeps({ startCodexConversationThread: vi.fn(async () => ({ kind: "codex-app-server-session" as const, version: 1 as const, sessionFile, workspaceDir: "/default", })), resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), }), }, ), ).resolves.toEqual(reply); }); it("clears the Codex app-server thread binding when conversation bind fails", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const clearCodexAppServerBinding = vi.fn(async () => {}); await expect( handleCodexCommand( createContext("bind", sessionFile, { requestConversationBinding: async () => ({ status: "error", message: "binding unsupported", }), }), { deps: createDeps({ clearCodexAppServerBinding, startCodexConversationThread: vi.fn(async () => ({ kind: "codex-app-server-session" as const, version: 1 as const, sessionFile, workspaceDir: "/default", })), resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"), }), }, ), ).resolves.toEqual({ text: "binding unsupported" }); expect(clearCodexAppServerBinding).toHaveBeenCalledWith(sessionFile); }); it("detaches the current conversation and clears the Codex app-server thread binding", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const clearCodexAppServerBinding = vi.fn(async () => {}); const detachConversationBinding = vi.fn(async () => ({ removed: true })); await expect( handleCodexCommand( createContext("detach", sessionFile, { detachConversationBinding, getCurrentConversationBinding: async () => ({ bindingId: "binding-1", pluginId: "codex", pluginRoot: "/plugin", channel: "test", accountId: "default", conversationId: "conversation", boundAt: 1, data: { kind: "codex-app-server-session", version: 1, sessionFile, workspaceDir: "/repo", }, }), }), { deps: createDeps({ clearCodexAppServerBinding }) }, ), ).resolves.toEqual({ text: "Detached this conversation from Codex.", }); expect(detachConversationBinding).toHaveBeenCalled(); expect(clearCodexAppServerBinding).toHaveBeenCalledWith(sessionFile); }); it("stops the active bound Codex turn", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const stopCodexConversationTurn = vi.fn(async () => ({ stopped: true, message: "Codex stop requested.", })); await expect( handleCodexCommand(createContext("stop", sessionFile), { deps: createDeps({ stopCodexConversationTurn }), }), ).resolves.toEqual({ text: "Codex stop requested." }); expect(stopCodexConversationTurn).toHaveBeenCalledWith({ sessionFile, pluginConfig: undefined, }); }); it("steers the active bound Codex turn", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const steerCodexConversationTurn = vi.fn(async () => ({ steered: true, message: "Sent steer message to Codex.", })); await expect( handleCodexCommand(createContext("steer focus tests first", sessionFile), { deps: createDeps({ steerCodexConversationTurn }), }), ).resolves.toEqual({ text: "Sent steer message to Codex." }); expect(steerCodexConversationTurn).toHaveBeenCalledWith({ sessionFile, pluginConfig: undefined, message: "focus tests first", }); }); it("sets per-binding model, fast mode, and permissions", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const setCodexConversationModel = vi.fn(async () => "Codex model set to gpt-5.4."); const setCodexConversationFastMode = vi.fn(async () => "Codex fast mode enabled."); const setCodexConversationPermissions = vi.fn( async () => "Codex permissions set to full access.", ); const deps = createDeps({ setCodexConversationModel, setCodexConversationFastMode, setCodexConversationPermissions, }); await expect( handleCodexCommand(createContext("model gpt-5.4", sessionFile), { deps }), ).resolves.toEqual({ text: "Codex model set to gpt-5.4." }); await expect( handleCodexCommand(createContext("fast on", sessionFile), { deps }), ).resolves.toEqual({ text: "Codex fast mode enabled." }); await expect( handleCodexCommand(createContext("permissions yolo", sessionFile), { deps }), ).resolves.toEqual({ text: "Codex permissions set to full access." }); expect(setCodexConversationModel).toHaveBeenCalledWith({ sessionFile, pluginConfig: undefined, model: "gpt-5.4", }); expect(setCodexConversationFastMode).toHaveBeenCalledWith({ sessionFile, pluginConfig: undefined, enabled: true, }); expect(setCodexConversationPermissions).toHaveBeenCalledWith({ sessionFile, pluginConfig: undefined, mode: "yolo", }); }); it("uses current plugin binding data for follow-up control commands", async () => { const hostSessionFile = path.join(tempDir, "host-session.jsonl"); const pluginSessionFile = path.join(tempDir, "plugin-session.jsonl"); const setCodexConversationFastMode = vi.fn(async () => "Codex fast mode enabled."); await expect( handleCodexCommand( createContext("fast on", pluginSessionFile, { getCurrentConversationBinding: async () => ({ bindingId: "binding-1", pluginId: "codex", pluginRoot: "/plugin", channel: "slack", accountId: "default", conversationId: "user:U123", boundAt: 1, data: { kind: "codex-app-server-session", version: 1, sessionFile: hostSessionFile, workspaceDir: tempDir, }, }), }), { deps: createDeps({ setCodexConversationFastMode, }), }, ), ).resolves.toEqual({ text: "Codex fast mode enabled." }); expect(setCodexConversationFastMode).toHaveBeenCalledWith({ sessionFile: hostSessionFile, pluginConfig: undefined, enabled: true, }); }); it("describes active binding preferences", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); await fs.writeFile( `${sessionFile}.codex-app-server.json`, JSON.stringify({ schemaVersion: 1, threadId: "thread-123", cwd: "/repo", model: "gpt-5.4", serviceTier: "fast", approvalPolicy: "never", sandbox: "danger-full-access", }), ); await expect( handleCodexCommand( createContext("binding", sessionFile, { getCurrentConversationBinding: async () => ({ bindingId: "binding-1", pluginId: "codex", pluginRoot: "/plugin", channel: "test", accountId: "default", conversationId: "conversation", boundAt: 1, data: { kind: "codex-app-server-session", version: 1, sessionFile, workspaceDir: "/repo", }, }), }), { deps: createDeps({ readCodexConversationActiveTurn: vi.fn(() => ({ sessionFile, threadId: "thread-123", turnId: "turn-1", })), }), }, ), ).resolves.toEqual({ text: [ "Codex conversation binding:", "- Thread: thread-123", "- Workspace: /repo", "- Model: gpt-5.4", "- Fast: on", "- Permissions: full access", "- Active run: turn-1", `- Session: ${sessionFile}`, ].join("\n"), }); }); }); function computerUseReadyStatus(): CodexComputerUseStatus { return { enabled: true, ready: true, reason: "ready", installed: true, pluginEnabled: true, mcpServerAvailable: true, pluginName: "computer-use", mcpServerName: "computer-use", marketplaceName: "desktop-tools", tools: ["list_apps"], message: "Computer Use is ready.", }; }