// Codex tests cover commands plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
  clearRuntimeAuthProfileStoreSnapshots,
  replaceRuntimeAuthProfileStoreSnapshots,
  resolveDefaultAgentDir,
  type AuthProfileStore,
} from "openclaw/plugin-sdk/agent-runtime";
import type { PluginCommandContext, PluginCommandResult } 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 type { JsonValue } from "./app-server/protocol.js";
import {
  readRecentCodexRateLimits,
  resetCodexRateLimitCacheForTests,
} from "./app-server/rate-limit-cache.js";
import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js";
import {
  resetCodexDiagnosticsFeedbackStateForTests,
  type CodexCommandDeps,
} from "./command-handlers.js";
import type {
  CodexPluginsConfigBlock,
  CodexPluginConfigEntry,
  CodexPluginsManagementIO,
} from "./command-plugins-management.js";
import { handleCodexCommand } from "./commands.js";

let tempDir: string;

function createContext(
  args: string,
  sessionFile?: string,
  overrides: Partial<PluginCommandContext> = {},
): PluginCommandContext {
  return {
    channel: "test",
    isAuthorizedSender: true,
    senderIsOwner: true,
    senderId: "user-1",
    args,
    commandBody: `/codex ${args}`,
    config: {},
    sessionFile,
    requestConversationBinding: async () => ({ status: "error", message: "unused" }),
    detachConversationBinding: async () => ({ removed: false }),
    getCurrentConversationBinding: async () => null,
    ...overrides,
  };
}

function createSandboxedContext(
  args: string,
  sessionFile?: string,
  overrides: Partial<PluginCommandContext> = {},
): PluginCommandContext {
  return createContext(args, sessionFile, {
    config: { agents: { defaults: { sandbox: { mode: "all" } } } },
    sessionKey: "sandboxed-session",
    ...overrides,
  } as Partial<PluginCommandContext>);
}

function createNodeExecContext(
  args: string,
  sessionFile?: string,
  overrides: Partial<PluginCommandContext> = {},
): PluginCommandContext {
  return createContext(args, sessionFile, {
    config: { tools: { exec: { host: "node", node: "worker-1" } } },
    sessionKey: "node-session",
    ...overrides,
  } as Partial<PluginCommandContext>);
}

function createDeps(overrides: Partial<CodexCommandDeps> = {}): Partial<CodexCommandDeps> {
  return {
    codexControlRequest: vi.fn(),
    listCodexAppServerModels: vi.fn(),
    readCodexStatusProbes: vi.fn(),
    requestOptions: vi.fn(
      (
        _pluginConfig: unknown,
        limit: number,
        config?: Parameters<NonNullable<CodexCommandDeps["requestOptions"]>>[2],
      ) => ({
        limit,
        timeoutMs: 1000,
        startOptions: {
          transport: "stdio",
          command: "codex",
          args: ["app-server", "--listen", "stdio://"],
          headers: {},
        } satisfies CodexAppServerStartOptions,
        config,
      }),
    ),
    safeCodexControlRequest: vi.fn(),
    ...overrides,
  };
}

function inMemoryCodexPluginsIO(
  initial: Record<string, CodexPluginConfigEntry> = {},
  options: { enabled?: boolean } = { enabled: true },
): CodexPluginsManagementIO & {
  current: () => Record<string, CodexPluginConfigEntry>;
  currentConfig: () => CodexPluginsConfigBlock;
} {
  const store: CodexPluginsConfigBlock = {
    enabled: options.enabled,
    plugins: structuredClone(initial),
  };
  return {
    current: () => structuredClone(store.plugins ?? {}),
    currentConfig: () => structuredClone(store),
    readConfig: () => Promise.resolve(structuredClone(store)),
    mutate: async (update) => {
      update(store);
    },
  };
}

function readDiagnosticsConfirmationToken(
  result: PluginCommandResult,
  commandPrefix = "/codex diagnostics",
): string {
  const text = result.text ?? "";
  const token = new RegExp(`${escapeRegExp(commandPrefix)} confirm ([a-f0-9]{12})`).exec(text)?.[1];
  if (!token) {
    throw new Error(`expected ${commandPrefix} confirmation token in command output`);
  }
  return token;
}

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function requireResultText(result: PluginCommandResult): string {
  if (typeof result.text !== "string") {
    throw new Error("expected command result text");
  }
  return result.text;
}

function expectResultTextContains(result: PluginCommandResult, expected: string): void {
  expect(requireResultText(result)).toContain(expected);
}

function buttonCommands(result: PluginCommandResult): string[] {
  const block = result.presentation?.blocks.find((candidate) => candidate.type === "buttons");
  if (!block || block.type !== "buttons") {
    throw new Error("expected button presentation");
  }
  return block.buttons.map((button) =>
    button.action?.type === "command" ? button.action.command : "",
  );
}

function installAuthProfileStore(store: AuthProfileStore, config: PluginCommandContext["config"]) {
  replaceRuntimeAuthProfileStoreSnapshots([
    {
      agentDir: resolveDefaultAgentDir(config),
      store,
    },
  ]);
}

function codexRateLimitPayload(params: {
  primaryUsedPercent: number;
  secondaryUsedPercent: number;
  primaryResetSeconds: number;
  secondaryResetSeconds: number;
  reached?: boolean;
}) {
  return {
    rateLimitsByLimitId: {
      codex: {
        limitId: "codex",
        limitName: "Codex",
        primary: {
          usedPercent: params.primaryUsedPercent,
          windowDurationMins: 300,
          resetsAt: params.primaryResetSeconds,
        },
        secondary: {
          usedPercent: params.secondaryUsedPercent,
          windowDurationMins: 10080,
          resetsAt: params.secondaryResetSeconds,
        },
        credits: null,
        planType: "plus",
        rateLimitReachedType: params.reached ? "rate_limit_reached" : null,
      },
    },
  };
}

function requireRecord(value: unknown, message: string): Record<string, unknown> {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(message);
  }
  return value as Record<string, unknown>;
}

function mockCall(mockFn: ReturnType<typeof vi.fn>, callIndex = 0): ReadonlyArray<unknown> {
  const call = mockFn.mock.calls[callIndex];
  if (!call) {
    throw new Error(`expected mock call ${callIndex + 1}`);
  }
  return call;
}

function mockArg(mockFn: ReturnType<typeof vi.fn>, callIndex: number, argIndex: number) {
  return mockCall(mockFn, callIndex)[argIndex];
}
function requestParams(mockFn: ReturnType<typeof vi.fn>, callIndex = 0): Record<string, unknown> {
  return requireRecord(mockArg(mockFn, callIndex, 2), "expected request params object");
}

function expectedDiagnosticsTargetBlock(params: {
  index?: number;
  channel?: string;
  sessionKey?: string;
  sessionId?: string;
  threadId: string;
}): string[] {
  return [
    `Session ${params.index ?? 1}`,
    ...(params.channel ? [`Channel: ${params.channel}`] : []),
    ...(params.sessionKey ? [`OpenClaw session key: \`${params.sessionKey}\``] : []),
    ...(params.sessionId ? [`OpenClaw session id: \`${params.sessionId}\``] : []),
    `Codex thread id: \`${params.threadId}\``,
    `Inspect locally: \`codex resume ${params.threadId}\``,
  ];
}

describe("codex command", () => {
  beforeEach(async () => {
    tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-command-"));
    vi.stubEnv("OPENCLAW_STATE_DIR", tempDir);
  });

  afterEach(async () => {
    resetCodexDiagnosticsFeedbackStateForTests();
    resetCodexRateLimitCacheForTests();
    resetSharedCodexAppServerClientForTests();
    clearRuntimeAuthProfileStoreSnapshots();
    vi.unstubAllEnvs();
    await fs.rm(tempDir, { recursive: true, force: true });
  });

  it("escapes unknown subcommands before chat display", async () => {
    const result = await handleCodexCommand(createContext("<@U123> [trusted](https://evil) @here"));

    expect(result.text).toContain("Unknown Codex command: &lt;\uff20U123&gt;");
    expect(result.text).not.toContain("<@U123>");
  });

  it("keeps command loader failures on the Codex command surface", async () => {
    const result = await handleCodexCommand(createContext("account"), {
      loadSubcommandHandler: async () => {
        throw new Error("<@U123> loader failed");
      },
    });

    expect(result.text).toContain("Codex command failed: &lt;\uff20U123&gt; loader failed");
    expect(result.text).not.toContain("<@U123>");
  });

  it("renders the top-level Codex menu as portable native slash commands", async () => {
    const result = await handleCodexCommand(createContext(""));

    expectResultTextContains(result, "/codex plugins menu");
    expect(buttonCommands(result)).toEqual([
      "/codex plugins menu",
      "/codex permissions menu",
      "/codex fast menu",
      "/codex computer-use menu",
      "/codex account",
      "/codex help",
    ]);
  });

  it("routes /codex plugins menu to the Codex-owned plugin picker", async () => {
    const codexPluginsManagementIo = inMemoryCodexPluginsIO();

    const result = await handleCodexCommand(createContext("plugins menu"), {
      deps: createDeps({ codexPluginsManagementIo }),
    });

    expectResultTextContains(result, "/codex plugins enable");
    expect(buttonCommands(result)).toContain("/codex plugins list");
  });

  it("lists Codex sub-plugins through the /codex plugins command surface", async () => {
    const codexPluginsManagementIo = inMemoryCodexPluginsIO({
      "google-calendar": {
        enabled: true,
        marketplaceName: "openai-curated",
        pluginName: "google-calendar",
      },
    });

    const result = await handleCodexCommand(createContext("plugins list"), {
      deps: createDeps({ codexPluginsManagementIo }),
    });

    expectResultTextContains(result, "ON   google-calendar");
    expectResultTextContains(result, "openclaw.json");
  });

  it("enables and disables Codex sub-plugins through the /codex plugins command surface", async () => {
    const codexPluginsManagementIo = inMemoryCodexPluginsIO({
      "google-calendar": {
        enabled: true,
        marketplaceName: "openai-curated",
        pluginName: "google-calendar",
      },
    });

    const disabled = await handleCodexCommand(createContext("plugins disable google-calendar"), {
      deps: createDeps({ codexPluginsManagementIo }),
    });
    expectResultTextContains(disabled, "google-calendar: disabled in openclaw.json");
    expect(codexPluginsManagementIo.current()["google-calendar"]?.enabled).toBe(false);

    const enabled = await handleCodexCommand(createContext("plugins enable google-calendar"), {
      deps: createDeps({ codexPluginsManagementIo }),
    });
    expectResultTextContains(enabled, "google-calendar: enabled in openclaw.json");
    expect(codexPluginsManagementIo.currentConfig().enabled).toBe(true);
    expect(codexPluginsManagementIo.current()["google-calendar"]?.enabled).toBe(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, requestParamsValue: unknown) => {
          requests.push({ method, params: requestParamsValue });
          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("rejects malformed resume commands before attaching a Codex thread", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const codexControlRequest = vi.fn();
    const writeCodexAppServerBinding = vi.fn();

    await expect(
      handleCodexCommand(createContext("resume thread-123 extra", sessionFile), {
        deps: createDeps({ codexControlRequest, writeCodexAppServerBinding }),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex resume <thread-id>",
    });
    expect(codexControlRequest).not.toHaveBeenCalled();
    expect(writeCodexAppServerBinding).not.toHaveBeenCalled();
  });

  it.each([
    "bind",
    "resume thread-123",
    "steer keep going",
    "steer keep going --help",
    "model --help",
    "model gpt-5.5",
    "fast on",
    "permissions yolo",
    "compact",
    "review",
  ])("blocks /codex %s in sandboxed sessions before native Codex execution", async (args) => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const codexControlRequest = vi.fn();
    const startCodexConversationThread = vi.fn();
    const steerCodexConversationTurn = vi.fn();
    const setCodexConversationModel = vi.fn();
    const setCodexConversationFastMode = vi.fn();
    const setCodexConversationPermissions = vi.fn();
    const stopCodexConversationTurn = vi.fn();

    const result = await handleCodexCommand(createSandboxedContext(args, sessionFile), {
      deps: createDeps({
        codexControlRequest,
        startCodexConversationThread,
        steerCodexConversationTurn,
        setCodexConversationModel,
        setCodexConversationFastMode,
        setCodexConversationPermissions,
        stopCodexConversationTurn,
      }),
    });

    expect(result.text).toContain(
      "Codex-native /codex " +
        args.split(/\s+/u)[0] +
        " is unavailable because OpenClaw sandboxing is active for this session.",
    );
    expect(codexControlRequest).not.toHaveBeenCalled();
    expect(startCodexConversationThread).not.toHaveBeenCalled();
    expect(steerCodexConversationTurn).not.toHaveBeenCalled();
    expect(setCodexConversationModel).not.toHaveBeenCalled();
    expect(setCodexConversationFastMode).not.toHaveBeenCalled();
    expect(setCodexConversationPermissions).not.toHaveBeenCalled();
    expect(stopCodexConversationTurn).not.toHaveBeenCalled();
  });

  it.each([
    "bind",
    "resume thread-123",
    "steer keep going",
    "model gpt-5.5",
    "fast on",
    "permissions yolo",
    "compact",
    "review",
  ])("blocks /codex %s when exec host=node is active", async (args) => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const codexControlRequest = vi.fn();
    const startCodexConversationThread = vi.fn();
    const steerCodexConversationTurn = vi.fn();
    const setCodexConversationModel = vi.fn();
    const setCodexConversationFastMode = vi.fn();
    const setCodexConversationPermissions = vi.fn();
    const stopCodexConversationTurn = vi.fn();

    const result = await handleCodexCommand(createNodeExecContext(args, sessionFile), {
      deps: createDeps({
        codexControlRequest,
        startCodexConversationThread,
        steerCodexConversationTurn,
        setCodexConversationModel,
        setCodexConversationFastMode,
        setCodexConversationPermissions,
        stopCodexConversationTurn,
      }),
    });

    expect(result.text).toContain(
      "Codex-native /codex " +
        args.split(/\s+/u)[0] +
        " is unavailable because OpenClaw exec host=node is active for this session.",
    );
    expect(codexControlRequest).not.toHaveBeenCalled();
    expect(startCodexConversationThread).not.toHaveBeenCalled();
    expect(steerCodexConversationTurn).not.toHaveBeenCalled();
    expect(setCodexConversationModel).not.toHaveBeenCalled();
    expect(setCodexConversationFastMode).not.toHaveBeenCalled();
    expect(setCodexConversationPermissions).not.toHaveBeenCalled();
    expect(stopCodexConversationTurn).not.toHaveBeenCalled();
  });

  it("blocks config-level exec host=node without a session key", async () => {
    const startCodexConversationThread = vi.fn();

    const result = await handleCodexCommand(
      createContext("bind", path.join(tempDir, "session.jsonl"), {
        config: { tools: { exec: { host: "node", node: "worker-1" } } },
      }),
      {
        deps: createDeps({
          startCodexConversationThread,
        }),
      },
    );

    expect(result.text).toContain(
      "Codex-native /codex bind is unavailable because OpenClaw exec host=node is active for this session.",
    );
    expect(startCodexConversationThread).not.toHaveBeenCalled();
  });

  it("still returns pre-native usage for malformed sandboxed native Codex commands", async () => {
    const startCodexConversationThread = vi.fn();
    const setCodexConversationModel = vi.fn();

    await expect(
      handleCodexCommand(createSandboxedContext("bind --help"), {
        deps: createDeps({ startCodexConversationThread }),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
    });
    expect(startCodexConversationThread).not.toHaveBeenCalled();

    await expect(
      handleCodexCommand(createSandboxedContext("model gpt-5.5 --help"), {
        deps: createDeps({ setCodexConversationModel }),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex model <model>",
    });
    expect(setCodexConversationModel).not.toHaveBeenCalled();

    await expect(
      handleCodexCommand(createSandboxedContext("resume"), { deps: createDeps() }),
    ).resolves.toEqual({
      text: "Usage: /codex resume <thread-id>",
    });

    const resolveCodexCliSessionForBindingOnNode = vi.fn();
    await expect(
      handleCodexCommand(createSandboxedContext("resume cli-1 --host node-1"), {
        deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex resume <session-id> --host <node> --bind here",
    });
    expect(resolveCodexCliSessionForBindingOnNode).not.toHaveBeenCalled();

    await expect(
      handleCodexCommand(createSandboxedContext("resume cli-1 --host node-1 --bind here extra"), {
        deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex resume <thread-id>\nUsage: /codex resume <session-id> --host <node> --bind here",
    });
    expect(resolveCodexCliSessionForBindingOnNode).not.toHaveBeenCalled();

    await expect(
      handleCodexCommand(createSandboxedContext("steer", path.join(tempDir, "session.jsonl")), {
        deps: createDeps(),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex steer <message>",
    });

    await expect(
      handleCodexCommand(createSandboxedContext("stop now"), {
        deps: createDeps({ stopCodexConversationTurn: vi.fn() }),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex stop",
    });
  });

  it("allows local Codex binding status forms in sandboxed sessions", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({
        schemaVersion: 1,
        threadId: "thread-status",
        cwd: tempDir,
        model: "gpt-5.5",
        approvalPolicy: "never",
        sandbox: "danger-full-access",
        serviceTier: "priority",
      }),
    );

    await expect(
      handleCodexCommand(createSandboxedContext("model", sessionFile), { deps: createDeps() }),
    ).resolves.toEqual({ text: "Codex model: gpt-5.5" });
    await expect(
      handleCodexCommand(createSandboxedContext("fast status", sessionFile), {
        deps: createDeps(),
      }),
    ).resolves.toEqual({ text: "Codex fast mode: on." });
    await expect(
      handleCodexCommand(createSandboxedContext("permissions status", sessionFile), {
        deps: createDeps(),
      }),
    ).resolves.toEqual({ text: "Codex permissions: full access." });
  });

  it("lists Codex CLI sessions from a requested node", async () => {
    const listCodexCliSessionsOnNode = vi.fn(async () => ({
      node: { nodeId: "mb-m5", displayName: "mb-m5" },
      result: {
        codexHome: "/Users/mariano/.codex",
        sessions: [
          {
            sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
            cwd: "/repo",
            updatedAt: "2026-05-13T06:30:00.000Z",
            lastMessage: "fix the bridge",
            messageCount: 2,
          },
        ],
      },
    }));

    const result = await handleCodexCommand(createContext("sessions --host mb-m5 bridge"), {
      deps: createDeps({ listCodexCliSessionsOnNode }),
    });

    expect(result.text).toContain("Codex CLI sessions on mb-m5 / mb-m5:");
    expect(result.text).toContain("019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd");
    expect(result.text).toContain(
      "Bind: /codex resume 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd --host mb-m5 --bind here",
    );
    expect(listCodexCliSessionsOnNode).toHaveBeenCalledWith({
      requestedNode: "mb-m5",
      filter: "bridge",
      limit: undefined,
    });
  });

  it("normalizes signed decimal Codex CLI session limits before node dispatch", async () => {
    const listCodexCliSessionsOnNode = vi.fn(async () => ({
      node: { nodeId: "mb-m5", displayName: "mb-m5" },
      result: {
        codexHome: "/Users/mariano/.codex",
        sessions: [],
      },
    }));

    await handleCodexCommand(createContext("sessions --host mb-m5 --limit +05 bridge"), {
      deps: createDeps({ listCodexCliSessionsOnNode }),
    });

    expect(listCodexCliSessionsOnNode).toHaveBeenCalledWith({
      requestedNode: "mb-m5",
      filter: "bridge",
      limit: 5,
    });
  });

  it("rejects partial Codex CLI session limits before node dispatch", async () => {
    const listCodexCliSessionsOnNode = vi.fn();

    const result = await handleCodexCommand(createContext("sessions --host mb-m5 --limit 5x"), {
      deps: createDeps({ listCodexCliSessionsOnNode }),
    });

    expect(result.text).toBe("Usage: /codex sessions --host <node> [filter] [--limit <n>]");
    expect(listCodexCliSessionsOnNode).not.toHaveBeenCalled();
  });

  it("rejects fractional Codex CLI session limits before node dispatch", async () => {
    const listCodexCliSessionsOnNode = vi.fn();

    const result = await handleCodexCommand(createContext("sessions --host mb-m5 --limit 5.5"), {
      deps: createDeps({ listCodexCliSessionsOnNode }),
    });

    expect(result.text).toBe("Usage: /codex sessions --host <node> [filter] [--limit <n>]");
    expect(listCodexCliSessionsOnNode).not.toHaveBeenCalled();
  });

  it("binds the current conversation to a Codex CLI node session", async () => {
    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,
      },
    }));
    const resolveCodexCliSessionForBindingOnNode = vi.fn(async () => ({
      node: { nodeId: "node-123", displayName: "mb-m5" },
      session: {
        sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
        cwd: "/repo",
        messageCount: 2,
      },
    }));

    await expect(
      handleCodexCommand(
        createNodeExecContext(
          "resume 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd --host mb-m5 --bind here",
          undefined,
          { requestConversationBinding },
        ),
        {
          deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
        },
      ),
    ).resolves.toEqual({
      text: "Bound this conversation to Codex CLI session 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd on node-123.",
    });
    expect(resolveCodexCliSessionForBindingOnNode).toHaveBeenCalledWith({
      requestedNode: "mb-m5",
      sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
    });
    expect(requestConversationBinding).toHaveBeenCalledWith({
      summary: "Codex CLI session 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd on node-123",
      detachHint: "/codex detach",
      data: {
        kind: "codex-cli-node-session",
        version: 1,
        nodeId: "node-123",
        sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
        cwd: "/repo",
      },
    });
  });

  it("refuses to bind a Codex CLI node session that the node did not list", async () => {
    const requestConversationBinding = vi.fn();
    const resolveCodexCliSessionForBindingOnNode = vi.fn(async () => ({
      node: { nodeId: "node-123", displayName: "mb-m5" },
      session: undefined,
    }));

    await expect(
      handleCodexCommand(
        createContext(
          "resume 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd --host mb-m5 --bind here",
          undefined,
          { requestConversationBinding },
        ),
        {
          deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
        },
      ),
    ).resolves.toEqual({
      text: "No Codex CLI session 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd was found on mb-m5.",
    });
    expect(requestConversationBinding).not.toHaveBeenCalled();
  });

  it("escapes resumed Codex thread ids before chat display", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const unsafe = "thread-123 <@U123> [trusted](https://evil)";
    const deps = createDeps({
      codexControlRequest: vi.fn(async () => ({
        thread: { id: unsafe, cwd: "/repo" },
      })),
    });

    const result = await handleCodexCommand(createContext("resume thread-123", sessionFile), {
      deps,
    });

    expect(result.text).toContain(
      "thread-123 &lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09",
    );
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
  });

  it("shows model ids from Codex app-server", async () => {
    const config = { auth: { order: { openai: ["openai:work"] } } };
    const listCodexAppServerModels = vi.fn(async (_options?: { config?: unknown }) => ({
      models: [
        {
          id: "gpt-5.4",
          model: "gpt-5.4",
          inputModalities: ["text"],
          supportedReasoningEfforts: ["medium"],
        },
      ],
    }));
    const deps = createDeps({
      listCodexAppServerModels,
    });

    await expect(
      handleCodexCommand(createContext("models", undefined, { config }), { deps }),
    ).resolves.toEqual({
      text: "Codex models:\n- gpt-5.4",
    });
    expect(deps.requestOptions).toHaveBeenCalledWith(undefined, 100, config);
    const modelsRequest = mockArg(listCodexAppServerModels, 0, 0) as { config?: unknown };
    expect(modelsRequest?.config).toBe(config);
  });

  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("escapes Codex app-server model ids before chat display", async () => {
    const deps = createDeps({
      listCodexAppServerModels: vi.fn(async () => ({
        models: [
          {
            id: "gpt-5.4 <@U123> [trusted](https://evil)",
            model: "gpt-5.4",
            inputModalities: ["text"],
            supportedReasoningEfforts: ["medium"],
          },
        ],
      })),
    });

    const result = await handleCodexCommand(createContext("models"), { deps });

    expect(result.text).toContain(
      "gpt-5.4 &lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09",
    );
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
  });

  it("escapes markdown underscores in Codex app-server readouts", async () => {
    const deps = createDeps({
      listCodexAppServerModels: vi.fn(async () => ({
        models: [
          {
            id: "unsafe_model_name",
            model: "unsafe_model_name",
            inputModalities: ["text"],
            supportedReasoningEfforts: ["medium"],
          },
        ],
      })),
    });

    const result = await handleCodexCommand(createContext("models"), { deps });

    expect(result.text).toContain("unsafe\uff3fmodel\uff3fname");
    expect(result.text).not.toContain("unsafe_model_name");
  });

  it("reports status unavailable when every Codex probe fails", async () => {
    const config = { auth: { order: { openai: ["openai:work"] } } };
    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", undefined, { config }), { deps }),
    ).resolves.toEqual({
      text: [
        "Codex app-server: unavailable",
        "Models: offline",
        "Account: offline",
        "Rate limits: offline",
        "MCP servers: offline",
        "Skills: offline",
      ].join("\n"),
    });
    expect(deps.readCodexStatusProbes).toHaveBeenCalledWith(undefined, config);
  });

  it("escapes Codex status probe errors before chat display", async () => {
    const unsafe = "<@U123> [trusted](https://evil) @here";
    const offline = { ok: false as const, error: unsafe };
    const deps = createDeps({
      readCodexStatusProbes: vi.fn(async () => ({
        models: offline,
        account: offline,
        limits: offline,
        mcps: offline,
        skills: offline,
      })),
    });

    const result = await handleCodexCommand(createContext("status"), { deps });

    expect(result.text).toContain(
      "&lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here",
    );
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
    expect(result.text).not.toContain("@here");
  });

  it("escapes successful Codex status model ids and account summaries", async () => {
    const unsafe = "<@U123> [trusted](https://evil) @here";
    const deps = createDeps({
      readCodexStatusProbes: vi.fn(async () => ({
        models: {
          ok: true as const,
          value: {
            models: [
              {
                id: unsafe,
                model: unsafe,
                inputModalities: ["text"],
                supportedReasoningEfforts: ["medium"],
              },
            ],
          },
        },
        account: {
          ok: true as const,
          value: {
            account: {
              type: "chatgpt" as const,
              email: unsafe,
              planType: "plus" as const,
            },
            requiresOpenaiAuth: false,
          },
        },
        limits: {
          ok: true as const,
          value: {
            rateLimits: {
              limitId: null,
              limitName: null,
              primary: null,
              secondary: null,
              credits: null,
              planType: null,
              rateLimitReachedType: null,
            },
            rateLimitsByLimitId: null,
          },
        },
        mcps: { ok: true as const, value: { data: [], nextCursor: null } },
        skills: { ok: true as const, value: { data: [] } },
      })),
    });

    const result = await handleCodexCommand(createContext("status"), { deps });

    expect(result.text).toContain(
      "&lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here",
    );
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
    expect(result.text).not.toContain("@here");
  });

  it("summarizes Codex status skill groups by enabled nested skills", async () => {
    const deps = createDeps({
      readCodexStatusProbes: vi.fn(async () => ({
        models: { ok: true as const, value: { models: [] } },
        account: { ok: true as const, value: {} },
        limits: { ok: true as const, value: { rateLimits: null, rateLimitsByLimitId: null } },
        mcps: { ok: true as const, value: { data: [] } },
        skills: {
          ok: true as const,
          value: {
            data: [
              {
                cwd: "/repo-a",
                skills: [
                  {
                    name: "enabled-one",
                    description: "",
                    path: "/repo-a/.codex/skills/enabled-one/SKILL.md",
                    scope: "repo" as const,
                    enabled: true,
                  },
                  {
                    name: "disabled-one",
                    description: "",
                    path: "/repo-a/.codex/skills/disabled-one/SKILL.md",
                    scope: "repo" as const,
                    enabled: false,
                  },
                ],
                errors: [],
              },
              {
                cwd: "/repo-b",
                skills: [
                  {
                    name: "enabled-two",
                    description: "",
                    path: "/repo-b/.codex/skills/enabled-two/SKILL.md",
                    scope: "repo" as const,
                    enabled: true,
                  },
                ],
                errors: [{ path: "/repo-b/bad/SKILL.md", message: "bad skill" }],
              },
            ],
          },
        },
      })),
    });

    const result = await handleCodexCommand(createContext("status"), { deps });

    expect(result.text).toContain("Skills: 2");
    expect(result.text).not.toContain("Skills: 1");
  });

  it("summarizes generated Codex rate-limit payloads", async () => {
    const limits = {
      ok: true as const,
      value: {
        rateLimits: {
          limitId: "codex",
          limitName: "Codex",
          primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null },
          secondary: null,
          credits: null,
          planType: null,
          rateLimitReachedType: null,
        },
        rateLimitsByLimitId: {
          codex: {
            limitId: "codex",
            limitName: "Codex",
            primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: null },
            secondary: null,
            credits: null,
            planType: null,
            rateLimitReachedType: null,
          },
        },
      },
    };
    const deps = createDeps({
      readCodexStatusProbes: vi.fn(async () => ({
        models: { ok: false as const, error: "offline" },
        account: { ok: false as const, error: "offline" },
        limits,
        mcps: { ok: true as const, value: { data: [], nextCursor: null } },
        skills: { ok: true as const, value: { data: [] } },
      })),
      safeCodexControlRequest: vi
        .fn()
        .mockResolvedValueOnce({
          ok: true as const,
          value: { account: { email: "codex@example.com" } },
        })
        .mockResolvedValueOnce(limits),
    });

    const statusResult = await handleCodexCommand(createContext("status"), { deps });
    expectResultTextContains(statusResult, "Rate limits: Codex: primary 58% left");
    const accountResult = await handleCodexCommand(createContext("account"), { deps });
    expectResultTextContains(accountResult, "Codex is available.");
  });

  it("does not count empty Codex rate-limit buckets as returned limits", async () => {
    const limits = {
      ok: true as const,
      value: {
        rateLimits: [
          {
            limitId: "codex",
            limitName: "Codex",
            primary: null,
            secondary: null,
            credits: null,
            planType: "plus",
            rateLimitReachedType: null,
          },
        ],
        rateLimitsByLimitId: {
          premium: {
            limitId: "premium",
            limitName: "premium",
            primary: null,
            secondary: null,
            credits: null,
            planType: "pro",
            rateLimitReachedType: null,
          },
          codex: {
            limitId: "codex",
            limitName: "Codex",
            primary: null,
            secondary: null,
            credits: null,
            planType: "plus",
            rateLimitReachedType: null,
          },
        },
      },
    };
    const deps = createDeps({
      readCodexStatusProbes: vi.fn(async () => ({
        models: { ok: false as const, error: "offline" },
        account: { ok: false as const, error: "offline" },
        limits,
        mcps: { ok: true as const, value: { data: [], nextCursor: null } },
        skills: { ok: true as const, value: { data: [] } },
      })),
      safeCodexControlRequest: vi
        .fn()
        .mockResolvedValueOnce({
          ok: true as const,
          value: { account: { email: "codex@example.com" } },
        })
        .mockResolvedValueOnce(limits),
    });

    const statusResult = await handleCodexCommand(createContext("status"), { deps });
    expectResultTextContains(statusResult, "Rate limits: none returned");
    expect(statusResult.text).not.toContain("Rate limits: 1");
    expect(statusResult.text).not.toContain("premium");

    const accountResult = await handleCodexCommand(createContext("account"), { deps });
    expectResultTextContains(accountResult, "Rate limits: none returned");
    expect(accountResult.text).not.toContain("Rate limits: 1");
    expect(accountResult.text).not.toContain("premium");
  });

  it("rejects extra operands for read-only Codex commands", async () => {
    const readCodexStatusProbes = vi.fn();
    const listCodexAppServerModels = vi.fn();
    const safeCodexControlRequest = vi.fn();
    const codexControlRequest = vi.fn();
    const getCurrentConversationBinding = vi.fn();
    const deps = createDeps({
      codexControlRequest,
      listCodexAppServerModels,
      readCodexStatusProbes,
      safeCodexControlRequest,
    });

    await expect(handleCodexCommand(createContext("status now"), { deps })).resolves.toEqual({
      text: "Usage: /codex status",
    });
    await expect(handleCodexCommand(createContext("models all"), { deps })).resolves.toEqual({
      text: "Usage: /codex models",
    });
    await expect(handleCodexCommand(createContext("account refresh"), { deps })).resolves.toEqual({
      text: "Usage: /codex account",
    });
    await expect(handleCodexCommand(createContext("mcp list"), { deps })).resolves.toEqual({
      text: "Usage: /codex mcp",
    });
    await expect(handleCodexCommand(createContext("skills list"), { deps })).resolves.toEqual({
      text: "Usage: /codex skills",
    });
    await expect(
      handleCodexCommand(
        createContext("binding current", undefined, {
          getCurrentConversationBinding,
        }),
        { deps },
      ),
    ).resolves.toEqual({
      text: "Usage: /codex binding",
    });

    expect(readCodexStatusProbes).not.toHaveBeenCalled();
    expect(listCodexAppServerModels).not.toHaveBeenCalled();
    expect(safeCodexControlRequest).not.toHaveBeenCalled();
    expect(codexControlRequest).not.toHaveBeenCalled();
    expect(getCurrentConversationBinding).not.toHaveBeenCalled();
  });

  it("formats generated account/read responses", async () => {
    const resetsAt = Math.ceil(Date.now() / 1000) + 120;
    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        value: {
          account: { type: "chatgpt", email: "codex@example.com", planType: "pro" },
          requiresOpenaiAuth: false,
        },
      })
      .mockResolvedValueOnce({
        ok: true,
        value: {
          rateLimits: {
            limitId: "codex",
            limitName: "Codex",
            primary: { usedPercent: 50, windowDurationMins: 300, resetsAt },
            secondary: null,
            credits: null,
            planType: "plus",
            rateLimitReachedType: null,
          },
          rateLimitsByLimitId: null,
        },
      });

    const result = await handleCodexCommand(createContext("account"), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain("Account: codex@example.com");
    expect(result.text).toContain("Codex is available.");
    const cachedLimits = requireRecord(
      readRecentCodexRateLimits(),
      "expected cached Codex rate limits",
    );
    expect(requireRecord(cachedLimits.rateLimits, "expected rate limits object").limitId).toBe(
      "codex",
    );
    expect(safeCodexControlRequest).toHaveBeenCalledWith(undefined, CODEX_CONTROL_METHODS.account, {
      refreshToken: false,
    });
  });

  it("escapes Codex account probe errors before chat display", async () => {
    const unsafe = "<@U123> [trusted](https://evil) @here";
    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({ ok: false as const, error: unsafe })
      .mockResolvedValueOnce({ ok: false as const, error: unsafe });

    const result = await handleCodexCommand(createContext("account"), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain(
      "&lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here",
    );
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
    expect(result.text).not.toContain("@here");
  });

  it("summarizes blocked account rate limits as a human takeaway", async () => {
    const resetsAt = Math.ceil(Date.now() / 1000) + 120;
    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        value: {
          account: { type: "chatgpt", email: "codex@example.com", planType: "pro" },
          requiresOpenaiAuth: false,
        },
      })
      .mockResolvedValueOnce({
        ok: true,
        value: {
          rateLimitsByLimitId: {
            codex: {
              limitId: "codex",
              limitName: "Codex",
              primary: { usedPercent: 0, windowDurationMins: 300, resetsAt },
              secondary: { usedPercent: 100, windowDurationMins: 10080, resetsAt: resetsAt + 3600 },
              credits: null,
              planType: "plus",
              rateLimitReachedType: "rate_limit_reached",
            },
            "gpt-5.3-codex-spark": {
              limitId: "gpt-5.3-codex-spark",
              limitName: "GPT 5.3 Codex Spark",
              primary: { usedPercent: 0, windowDurationMins: 300, resetsAt },
              secondary: { usedPercent: 0, windowDurationMins: 10080, resetsAt: resetsAt + 3600 },
              credits: null,
              planType: "plus",
              rateLimitReachedType: null,
            },
          },
        },
      });

    const result = await handleCodexCommand(createContext("account"), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain("Codex is paused until ");
    expect(result.text).toContain("Your weekly Codex usage limit is reached.");
    expect(result.text).not.toContain("GPT 5.3 Codex Spark");
    expect(result.text).not.toContain("Primary:");
    expect(result.text).not.toContain("Secondary:");
    expect(result.text).not.toContain("Bucket:");
    expect(result.text).not.toContain("Why:");
    expect(result.text).not.toContain("5-hour");
    expect(result.text).not.toContain("100%");
    expect(result.text).not.toContain("; GPT 5.3 Codex Spark");
    expect(result.text).not.toContain("\uff08rate limit reached\uff09");
  });

  it("shows the active ChatGPT subscription and API-key backup ladder", async () => {
    const config = {};
    const now = Date.now();
    const resetsAt = Math.ceil(now / 1000) + 120;
    installAuthProfileStore(
      {
        version: 1,
        profiles: {
          "openai:personal-email@gmail.com": {
            type: "oauth",
            provider: "openai",
            access: "access-token",
            refresh: "refresh-token",
            expires: now + 60 * 60 * 1000,
            email: "personal-email@gmail.com",
          },
          "openai:api-key-backup": {
            type: "api_key",
            provider: "openai",
            key: "sk-test-backup",
          },
        },
        order: {
          openai: ["openai:personal-email@gmail.com", "openai:api-key-backup"],
        },
        lastGood: {
          openai: "openai:personal-email@gmail.com",
        },
        usageStats: {
          "openai:personal-email@gmail.com": {
            lastUsed: now - 1_000,
          },
        },
      },
      config,
    );

    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        value: {
          account: { type: "chatgpt", email: "personal-email@gmail.com", planType: "pro" },
          requiresOpenaiAuth: false,
        },
      })
      .mockResolvedValueOnce({
        ok: true,
        value: codexRateLimitPayload({
          primaryUsedPercent: 12,
          secondaryUsedPercent: 63,
          primaryResetSeconds: resetsAt,
          secondaryResetSeconds: resetsAt + 3600,
        }),
      });

    const result = await handleCodexCommand(createContext("account", undefined, { config }), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain("Subscription  personal-email@gmail.com");
    expect(result.text).toContain("\n  Weekly 63% \u00b7 Short-term 12%");
    expect(result.text).toContain("Auth order");
    expect(result.text).toContain(
      "\n  1. personal-email@gmail.com   ChatGPT subscription   — active now",
    );
    expect(result.text).toContain("\n  2. api-key-backup   API key   — available if needed");
    expect(result.text).not.toContain("Now using:");
    expect(result.text).not.toContain("openai:api-key-backup");
    expect(result.text).not.toContain("primary");
    expect(result.text).not.toContain("secondary");
  });

  it("prefers the live ChatGPT account over stale API-key lastGood state", async () => {
    const config = {};
    const now = Date.now();
    installAuthProfileStore(
      {
        version: 1,
        profiles: {
          "openai:personal-email@gmail.com": {
            type: "oauth",
            provider: "openai",
            access: "access-token",
            refresh: "refresh-token",
            expires: now + 60 * 60 * 1000,
            email: "personal-email@gmail.com",
          },
          "openai:api-key-backup": {
            type: "api_key",
            provider: "openai",
            key: "sk-test-backup",
          },
        },
        order: {
          openai: ["openai:personal-email@gmail.com", "openai:api-key-backup"],
        },
        lastGood: {
          openai: "openai:api-key-backup",
        },
      },
      config,
    );

    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        value: {
          account: { type: "chatgpt", email: "personal-email@gmail.com", planType: "pro" },
          requiresOpenaiAuth: false,
        },
      })
      .mockResolvedValueOnce({
        ok: true,
        value: codexRateLimitPayload({
          primaryUsedPercent: 12,
          secondaryUsedPercent: 63,
          primaryResetSeconds: Math.ceil(now / 1000) + 120,
          secondaryResetSeconds: Math.ceil(now / 1000) + 3600,
        }),
      });

    const result = await handleCodexCommand(createContext("account", undefined, { config }), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain(
      "\n  1. personal-email@gmail.com   ChatGPT subscription   — active now",
    );
    expect(result.text).toContain("\n  2. api-key-backup   API key   — available if needed");
    expect(result.text).not.toContain("Now using: api-key-backup");
    expect(result.text).not.toContain("subscription unavailable");
  });

  it("shows OpenAI subscription auth before API-key fallback order", async () => {
    const config = {
      auth: {
        order: {
          openai: ["openai:personal-email@gmail.com", "openai:api-key"],
        },
      },
    };
    const now = Date.now();
    installAuthProfileStore(
      {
        version: 1,
        profiles: {
          "openai:api-key": {
            type: "api_key",
            provider: "openai",
            key: "sk-test",
          },
          "openai:personal-email@gmail.com": {
            type: "oauth",
            provider: "openai",
            access: "access-token",
            refresh: "refresh-token",
            expires: now + 60 * 60 * 1000,
            email: "personal-email@gmail.com",
          },
        },
        lastGood: {
          openai: "openai:personal-email@gmail.com",
        },
      },
      config,
    );

    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        value: {
          account: { type: "chatgpt", email: "personal-email@gmail.com", planType: "plus" },
          requiresOpenaiAuth: false,
        },
      })
      .mockResolvedValueOnce({
        ok: true,
        value: codexRateLimitPayload({
          primaryUsedPercent: 10,
          secondaryUsedPercent: 20,
          primaryResetSeconds: Math.ceil(now / 1000) + 120,
          secondaryResetSeconds: Math.ceil(now / 1000) + 3600,
        }),
      });

    const result = await handleCodexCommand(createContext("account", undefined, { config }), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain(
      "\n  1. personal-email@gmail.com   ChatGPT subscription   — active now",
    );
    expect(result.text).toContain("\n  2. api-key   API key   — available if needed");
  });

  it("explains when an API-key backup is active because the subscription is paused", async () => {
    const config = {};
    const now = Date.now();
    const primaryResetSeconds = Math.ceil(now / 1000) + 5 * 60 * 60;
    const secondaryResetSeconds = Math.ceil(now / 1000) + 23 * 60 * 60;
    installAuthProfileStore(
      {
        version: 1,
        profiles: {
          "openai:personal-email@gmail.com": {
            type: "oauth",
            provider: "openai",
            access: "access-token",
            refresh: "refresh-token",
            expires: now + 60 * 60 * 1000,
            email: "personal-email@gmail.com",
          },
          "openai:api-key-backup": {
            type: "api_key",
            provider: "openai",
            key: "sk-test-backup",
          },
          "openai:work-email@gmail.com": {
            type: "oauth",
            provider: "openai",
            access: "work-access-token",
            refresh: "work-refresh-token",
            expires: now + 60 * 60 * 1000,
            email: "work-email@gmail.com",
          },
          "openai:work-api-key-backup": {
            type: "api_key",
            provider: "openai",
            key: "sk-test-work-backup",
          },
        },
        order: {
          openai: [
            "openai:personal-email@gmail.com",
            "openai:api-key-backup",
            "openai:work-email@gmail.com",
            "openai:work-api-key-backup",
          ],
        },
      },
      config,
    );

    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        value: {
          account: { type: "apiKey" },
          requiresOpenaiAuth: true,
        },
      })
      .mockResolvedValueOnce({
        ok: false,
        error: "chatgpt authentication required to read rate limits",
      })
      .mockResolvedValueOnce({
        ok: true,
        value: codexRateLimitPayload({
          primaryUsedPercent: 0,
          secondaryUsedPercent: 100,
          primaryResetSeconds,
          secondaryResetSeconds,
          reached: true,
        }),
      });

    const result = await handleCodexCommand(createContext("account", undefined, { config }), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain("Now using: api-key-backup");
    expect(result.text).toContain("subscription rate-limited \u00b7 switches back in");
    expect(result.text).toContain("Subscription  personal-email@gmail.com");
    expect(result.text).toContain("\n  Weekly 100% \u00b7 Short-term 0% \u00b7 Resets in");
    expect(result.text).toContain(
      "\n  1. personal-email@gmail.com   ChatGPT subscription   — rate-limited",
    );
    expect(result.text).toContain(
      "\n  2. api-key-backup   API key   — active now \u00b7 billed per token",
    );
    expect(result.text).toContain(
      "\n  3. work-email@gmail.com   ChatGPT subscription   — available if needed",
    );
    expect(result.text).toContain("\n  4. work-api-key-backup   API key   — available if needed");
    expect(result.text).not.toContain("Reason:");
    expect(result.text).not.toContain("fallback active");
    expect(result.text).not.toContain("not tracked");
    expect(result.text).not.toContain("chatgpt authentication required");
    expect(result.text).not.toContain("openai:");
    expect(result.text).not.toContain("primary");
    expect(result.text).not.toContain("secondary");
    expect(safeCodexControlRequest).toHaveBeenNthCalledWith(
      3,
      undefined,
      CODEX_CONTROL_METHODS.rateLimits,
      undefined,
      {
        config,
        authProfileId: "openai:personal-email@gmail.com",
        isolated: true,
      },
    );
  });

  it("does not report a blocked last-good subscription as active", async () => {
    const config = {};
    const now = Date.now();
    const primaryResetSeconds = Math.ceil(now / 1000) + 5 * 60 * 60;
    const secondaryResetSeconds = Math.ceil(now / 1000) + 23 * 60 * 60;
    installAuthProfileStore(
      {
        version: 1,
        profiles: {
          "openai:personal-email@gmail.com": {
            type: "oauth",
            provider: "openai",
            access: "access-token",
            refresh: "refresh-token",
            expires: now + 60 * 60 * 1000,
            email: "personal-email@gmail.com",
          },
          "openai:api-key-backup": {
            type: "api_key",
            provider: "openai",
            key: "sk-test-backup",
          },
        },
        order: {
          openai: ["openai:personal-email@gmail.com", "openai:api-key-backup"],
        },
        lastGood: {
          openai: "openai:personal-email@gmail.com",
        },
        usageStats: {
          "openai:personal-email@gmail.com": {
            lastUsed: now - 1_000,
            blockedUntil: now + 23 * 60 * 60 * 1000,
          },
        },
      },
      config,
    );

    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        value: {
          account: { type: "unknown" },
          requiresOpenaiAuth: true,
        },
      })
      .mockResolvedValueOnce({
        ok: false,
        error: "chatgpt authentication required to read rate limits",
      })
      .mockResolvedValueOnce({
        ok: true,
        value: codexRateLimitPayload({
          primaryUsedPercent: 0,
          secondaryUsedPercent: 100,
          primaryResetSeconds,
          secondaryResetSeconds,
          reached: true,
        }),
      });

    const result = await handleCodexCommand(createContext("account", undefined, { config }), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain("Now using: api-key-backup");
    expect(result.text).toContain("subscription rate-limited");
    expect(result.text).toContain(
      "\n  1. personal-email@gmail.com   ChatGPT subscription   — rate-limited",
    );
    expect(result.text).toContain(
      "\n  2. api-key-backup   API key   — active now \u00b7 billed per token",
    );
    expect(result.text).not.toContain(
      "personal-email@gmail.com   ChatGPT subscription   — active now",
    );
  });

  it("respects explicit Codex auth order over stale lastGood after OAuth re-login", async () => {
    const config = {};
    const now = Date.now();
    installAuthProfileStore(
      {
        version: 1,
        profiles: {
          "openai:default": {
            type: "oauth",
            provider: "openai",
            access: "stale-access-token",
            refresh: "stale-refresh-token",
            expires: now + 2 * 24 * 60 * 60 * 1000,
            email: "previous@example.com",
          },
          "openai:fresh-email@example.com": {
            type: "oauth",
            provider: "openai",
            access: "fresh-access-token",
            refresh: "fresh-refresh-token",
            expires: now + 9 * 24 * 60 * 60 * 1000,
            email: "fresh-email@example.com",
          },
        },
        order: {
          openai: ["openai:fresh-email@example.com", "openai:default"],
        },
        lastGood: {
          openai: "openai:default",
        },
      },
      config,
    );

    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        value: { account: { type: "unknown" }, requiresOpenaiAuth: true },
      })
      .mockResolvedValueOnce({
        ok: true,
        value: codexRateLimitPayload({
          primaryUsedPercent: 5,
          secondaryUsedPercent: 10,
          primaryResetSeconds: Math.ceil(now / 1000) + 60 * 60,
          secondaryResetSeconds: Math.ceil(now / 1000) + 6 * 60 * 60,
        }),
      });

    const result = await handleCodexCommand(createContext("account", undefined, { config }), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain(
      "\n  1. fresh-email@example.com   ChatGPT subscription   — active now",
    );
    expect(result.text).toContain(
      "\n  2. previous@example.com   ChatGPT subscription   — available if needed",
    );
    expect(result.text).not.toContain("previous@example.com   ChatGPT subscription   — active now");
    expect(result.text).not.toContain("openai:");
    expect(safeCodexControlRequest).toHaveBeenCalledTimes(2);
  });

  it("respects openai-alias explicit order over stale lastGood for API key profiles", async () => {
    const config = {};
    const ignoredNow = Date.now();
    void ignoredNow;
    installAuthProfileStore(
      {
        version: 1,
        profiles: {
          "openai:fresh-key": {
            type: "api_key",
            provider: "openai",
            key: "sk-fresh-111",
          },
          "openai:stale-key": {
            type: "api_key",
            provider: "openai",
            key: "sk-stale-222",
          },
        },
        order: {
          openai: ["openai:fresh-key", "openai:stale-key"],
        },
        lastGood: {
          openai: "openai:stale-key",
        },
      },
      config,
    );

    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        value: { account: { type: "unknown" }, requiresOpenaiAuth: false },
      })
      .mockResolvedValueOnce({
        ok: false,
        error: "usage data unavailable",
      });

    const result = await handleCodexCommand(createContext("account", undefined, { config }), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain("\n  1. fresh-key   API key   — active now");
    expect(result.text).not.toContain("stale-key   API key   — active now");
    expect(safeCodexControlRequest).toHaveBeenCalledTimes(2);
  });

  it("does not mark any profile active when all explicit-order token credentials are expired", async () => {
    // Both profiles use type:"token" with expired expiry, so resolveAuthProfileEligibility
    // returns eligible=false for both. resolveActiveProfileId must return undefined rather
    // than marking an ineligible profile as active; the display shows "no working credential".
    const config = {};
    const now = Date.now();
    installAuthProfileStore(
      {
        version: 1,
        profiles: {
          "openai:fresh@example.com": {
            type: "token",
            provider: "openai",
            token: "fresh-token",
            expires: now - 1000,
            email: "fresh@example.com",
          },
          "openai:stale@example.com": {
            type: "token",
            provider: "openai",
            token: "stale-token",
            expires: now - 2000,
            email: "stale@example.com",
          },
        },
        order: {
          openai: ["openai:fresh@example.com", "openai:stale@example.com"],
        },
        lastGood: {
          openai: "openai:stale@example.com",
        },
      },
      config,
    );

    const safeCodexControlRequest = vi
      .fn()
      // call 1: account info for the active/first profile
      .mockResolvedValueOnce({
        ok: true,
        value: { account: { type: "unknown" }, requiresOpenaiAuth: true },
      })
      // call 2: rate limits for the active profile
      .mockResolvedValueOnce({
        ok: false,
        error: "rate limits unavailable",
      })
      // call 3: readSubscriptionUsage — no activeProfileId means the subscription
      // profile (fresh, type:"token") is fetched separately
      .mockResolvedValueOnce({
        ok: false,
        error: "subscription limits unavailable",
      });

    const result = await handleCodexCommand(createContext("account", undefined, { config }), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    // With all credentials expired, no profile is active — the display shows
    // "no working credential" and both profiles are labelled "sign-in expired".
    // lastGood (stale) must not override the stated operator rank, and the
    // first explicit-order entry must not be falsely marked active when ineligible.
    expect(result.text).toContain("no working credential");
    expect(result.text).toContain(
      "\n  1. fresh@example.com   ChatGPT subscription   — sign-in expired",
    );
    expect(result.text).toContain(
      "\n  2. stale@example.com   ChatGPT subscription   — sign-in expired",
    );
    expect(result.text).not.toContain("active now");
    expect(safeCodexControlRequest).toHaveBeenCalledTimes(3);
  });

  it("escapes successful Codex account fallback summaries before chat display", async () => {
    const unsafe = "<@U123> [trusted](https://evil) @here";
    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({ ok: true as const, value: { account: { id: unsafe } } })
      .mockResolvedValueOnce({ ok: true as const, value: [] });

    const result = await handleCodexCommand(createContext("account"), {
      deps: createDeps({ safeCodexControlRequest }),
    });

    expect(result.text).toContain(
      "&lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here",
    );
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
    expect(result.text).not.toContain("@here");
  });

  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\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",
      },
      {
        agentDir: path.join(tempDir, "agents", "main", "agent"),
        authProfileId: undefined,
        config: {},
      },
    );
  });

  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" },
      },
      {
        agentDir: path.join(tempDir, "agents", "main", "agent"),
        authProfileId: undefined,
        config: {},
      },
    );
  });

  it("rejects malformed compact and review commands before starting thread actions", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const codexControlRequest = vi.fn();

    await expect(
      handleCodexCommand(createContext("compact now", sessionFile), {
        deps: createDeps({ codexControlRequest }),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex compact",
    });
    await expect(
      handleCodexCommand(createContext("review staged", sessionFile), {
        deps: createDeps({ codexControlRequest }),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex review",
    });
    expect(codexControlRequest).not.toHaveBeenCalled();
  });

  it("escapes started thread-action ids before chat display", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-123 <@U123>", cwd: "/repo" }),
    );
    const codexControlRequest = vi.fn(async () => ({}));

    const result = await handleCodexCommand(createContext("compact", sessionFile), {
      deps: createDeps({ codexControlRequest }),
    });

    expect(result.text).toContain("thread-123 &lt;\uff20U123&gt;");
    expect(result.text).not.toContain("<@U123>");
  });

  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\uff3fapps",
        "Computer Use is ready.",
      ].join("\n"),
    });
    expect(readCodexComputerUseStatus).toHaveBeenCalledWith({
      pluginConfig: undefined,
      forceEnable: false,
    });
  });

  it("escapes Codex Computer Use status fields before chat display", async () => {
    const readCodexComputerUseStatus = vi.fn(async () => ({
      ...computerUseReadyStatus(),
      pluginName: "<@U123>",
      mcpServerName: "computer-use [server](https://evil)",
      marketplaceName: "desktop_tools",
      tools: ["list_apps", "[click](https://evil)"],
      message: "Computer Use is ready @here.",
    }));

    const result = await handleCodexCommand(createContext("computer-use status"), {
      deps: createDeps({ readCodexComputerUseStatus }),
    });

    expect(result.text).toContain("Plugin: &lt;\uff20U123&gt; (installed)");
    expect(result.text).toContain(
      "MCP server: computer-use \uff3bserver\uff3d\uff08https://evil\uff09 (2 tools)",
    );
    expect(result.text).toContain("Marketplace: desktop\uff3ftools");
    expect(result.text).toContain(
      "Tools: list\uff3fapps, \uff3bclick\uff3d\uff08https://evil\uff09",
    );
    expect(result.text).toContain("Computer Use is ready \uff20here.");
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[click](https://evil)");
    expect(result.text).not.toContain("@here");
  });

  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.",
    }));

    const result = await handleCodexCommand(createContext("computer-use status"), {
      deps: createDeps({ readCodexComputerUseStatus }),
    });

    expectResultTextContains(result, "Plugin: computer-use (installed, disabled)");
  });

  it("installs Codex Computer Use from command overrides", async () => {
    const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());

    const result = await handleCodexCommand(
      createContext(
        "computer-use install --source github:example/desktop-tools --marketplace desktop-tools",
      ),
      {
        deps: createDeps({ installCodexComputerUse }),
      },
    );

    expectResultTextContains(result, "Computer Use: ready");
    expect(installCodexComputerUse).toHaveBeenCalledWith({
      pluginConfig: undefined,
      forceEnable: true,
      overrides: {
        marketplaceSource: "github:example/desktop-tools",
        marketplaceName: "desktop-tools",
      },
    });
  });

  it("rejects Computer Use installation from non-owner non-admin callers", async () => {
    const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());
    const ctx = createContext(
      "computer-use install --source attacker/marketplace --plugin untrusted",
      undefined,
      { senderIsOwner: false, gatewayClientScopes: ["operator.write"] },
    );

    const result = await handleCodexCommand(ctx, {
      deps: createDeps({ installCodexComputerUse }),
    });

    expectResultTextContains(result, "Only an owner or operator.admin");
    expect(installCodexComputerUse).not.toHaveBeenCalled();
  });

  it("keeps Computer Use status overrides read-only for non-owner callers", async () => {
    const readCodexComputerUseStatus = vi.fn(async () => computerUseReadyStatus());
    const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());
    const ctx = createContext(
      "computer-use status --source existing/source --marketplace-path /existing/marketplace --marketplace existing --plugin existing-plugin --mcp-server existing-server",
      undefined,
      {
        senderIsOwner: false,
        gatewayClientScopes: ["operator.write"],
      },
    );

    const result = await handleCodexCommand(ctx, {
      deps: createDeps({ readCodexComputerUseStatus, installCodexComputerUse }),
    });

    expectResultTextContains(result, "Computer Use: ready");
    expect(readCodexComputerUseStatus).toHaveBeenCalledWith({
      pluginConfig: undefined,
      forceEnable: true,
      overrides: {
        marketplaceSource: "existing/source",
        marketplacePath: "/existing/marketplace",
        marketplaceName: "existing",
        pluginName: "existing-plugin",
        mcpServerName: "existing-server",
      },
    });
    expect(installCodexComputerUse).not.toHaveBeenCalled();
  });

  it("allows operator.admin gateway callers to install Codex Computer Use", async () => {
    const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());
    const ctx = createContext("computer-use install", undefined, {
      senderIsOwner: false,
      gatewayClientScopes: ["operator.admin"],
    });

    const result = await handleCodexCommand(ctx, {
      deps: createDeps({ installCodexComputerUse }),
    });

    expectResultTextContains(result, "Computer Use: ready");
    expect(installCodexComputerUse).toHaveBeenCalledOnce();
  });

  it("shows help when Computer Use option values are missing", async () => {
    const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());

    const result = await handleCodexCommand(createContext("computer-use install --source"), {
      deps: createDeps({ installCodexComputerUse }),
    });

    expectResultTextContains(result, "Usage: /codex computer-use");
    expect(installCodexComputerUse).not.toHaveBeenCalled();
  });

  it("rejects ambiguous Computer Use actions before setup checks", async () => {
    const readCodexComputerUseStatus = vi.fn(async () => computerUseReadyStatus());
    const installCodexComputerUse = vi.fn(async () => computerUseReadyStatus());

    const result = await handleCodexCommand(createContext("computer-use status install"), {
      deps: createDeps({ readCodexComputerUseStatus, installCodexComputerUse }),
    });

    expectResultTextContains(result, "Usage: /codex computer-use");
    expect(readCodexComputerUseStatus).not.toHaveBeenCalled();
    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("asks before sending diagnostics feedback 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 safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-123" },
    }));
    const deps = createDeps({ safeCodexControlRequest });

    const request = await handleCodexCommand(
      createContext("diagnostics tool loop repro", sessionFile, {
        senderId: "user-1",
        sessionId: "session-1",
        sessionKey: "agent:main:session-1",
      }),
      { deps },
    );

    const token = readDiagnosticsConfirmationToken(request);
    expect(request.text).toBe(
      [
        "Codex runtime thread detected.",
        "Codex diagnostics can send this thread's feedback bundle to OpenAI servers.",
        "Codex sessions:",
        ...expectedDiagnosticsTargetBlock({
          channel: "test",
          sessionKey: "agent:main:session-1",
          sessionId: "session-1",
          threadId: "thread-123",
        }),
        "Note: tool loop repro",
        "Included: Codex logs and spawned Codex subthreads when available.",
        `To send: /codex diagnostics confirm ${token}`,
        `To cancel: /codex diagnostics cancel ${token}`,
        "This request expires in 5 minutes.",
      ].join("\n"),
    );
    expect(request.interactive).toEqual({
      blocks: [
        {
          type: "buttons",
          buttons: [
            {
              label: "Send diagnostics",
              action: { type: "command", command: `/codex diagnostics confirm ${token}` },
              value: `/codex diagnostics confirm ${token}`,
              style: "danger",
            },
            {
              label: "Cancel",
              action: { type: "command", command: `/codex diagnostics cancel ${token}` },
              value: `/codex diagnostics cancel ${token}`,
              style: "secondary",
            },
          ],
        },
      ],
    });
    expect(safeCodexControlRequest).not.toHaveBeenCalled();

    await expect(
      handleCodexCommand(
        createContext(`diagnostics confirm ${token}`, sessionFile, {
          senderId: "user-1",
          sessionId: "session-1",
          sessionKey: "agent:main:session-1",
        }),
        { deps },
      ),
    ).resolves.toEqual({
      text: [
        "Codex diagnostics sent to OpenAI servers:",
        ...expectedDiagnosticsTargetBlock({
          channel: "test",
          sessionKey: "agent:main:session-1",
          sessionId: "session-1",
          threadId: "thread-123",
        }),
        "Included Codex logs and spawned Codex subthreads when available.",
      ].join("\n"),
    });
    expect(safeCodexControlRequest).toHaveBeenCalledWith(
      undefined,
      CODEX_CONTROL_METHODS.feedback,
      {
        classification: "bug",
        reason: "tool loop repro",
        threadId: "thread-123",
        includeLogs: true,
        tags: {
          source: "openclaw-diagnostics",
          channel: "test",
        },
      },
    );
  });

  it("rejects malformed diagnostics confirmation commands without consuming the token", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-confirm-args", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-confirm-args" },
    }));
    const deps = createDeps({ safeCodexControlRequest });

    const request = await handleCodexCommand(createContext("diagnostics", sessionFile), { deps });
    const token = readDiagnosticsConfirmationToken(request);

    await expect(
      handleCodexCommand(createContext(`diagnostics confirm ${token} extra`, sessionFile), {
        deps,
      }),
    ).resolves.toEqual({
      text: [
        "Usage: /codex diagnostics [note]",
        "Usage: /codex diagnostics confirm <token>",
        "Usage: /codex diagnostics cancel <token>",
      ].join("\n"),
    });
    await expect(
      handleCodexCommand(createContext(`diagnostics cancel ${token} extra`, sessionFile), {
        deps,
      }),
    ).resolves.toEqual({
      text: [
        "Usage: /codex diagnostics [note]",
        "Usage: /codex diagnostics confirm <token>",
        "Usage: /codex diagnostics cancel <token>",
      ].join("\n"),
    });
    expect(safeCodexControlRequest).not.toHaveBeenCalled();

    const confirmResult = await handleCodexCommand(
      createContext(`diagnostics confirm ${token}`, sessionFile),
      { deps },
    );
    expectResultTextContains(confirmResult, "Codex diagnostics sent to OpenAI servers:");
    expect(safeCodexControlRequest).toHaveBeenCalledTimes(1);
  });

  it("previews exec-approved diagnostics upload without exposing Codex ids", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-preview", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-preview" },
    }));

    const result = await handleCodexCommand(
      createContext("diagnostics flaky tool call", sessionFile, {
        diagnosticsPreviewOnly: true,
        senderId: "user-1",
        sessionId: "session-preview",
        sessionKey: "agent:main:telegram:preview",
      }),
      { deps: createDeps({ safeCodexControlRequest }) },
    );

    expect(result.text).toBe(
      [
        "Codex runtime thread detected.",
        "Approving diagnostics will also send this thread's feedback bundle to OpenAI servers.",
        "The completed diagnostics reply will list the OpenClaw session ids and Codex thread ids that were sent.",
        "Note: flaky tool call",
        "Included: Codex logs and spawned Codex subthreads when available.",
      ].join("\n"),
    );
    expect(result.text).not.toContain("thread-preview");
    expect(result.text).not.toContain("session-preview");
    expect(result.text).not.toContain("agent:main:telegram:preview");
    expect(result.text).not.toContain("To send:");
    expect(result.interactive).toBeUndefined();
    expect(safeCodexControlRequest).not.toHaveBeenCalled();
  });

  it("sends diagnostics feedback immediately after exec approval", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-approved", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-approved" },
    }));
    const deps = createDeps({ safeCodexControlRequest });

    await expect(
      handleCodexCommand(
        createContext("diagnostics approved repro", sessionFile, {
          diagnosticsUploadApproved: true,
          senderId: "user-1",
          sessionId: "session-approved",
          sessionKey: "agent:main:telegram:approved",
        }),
        { deps },
      ),
    ).resolves.toEqual({
      text: [
        "Codex diagnostics sent to OpenAI servers:",
        ...expectedDiagnosticsTargetBlock({
          channel: "test",
          sessionKey: "agent:main:telegram:approved",
          sessionId: "session-approved",
          threadId: "thread-approved",
        }),
        "Included Codex logs and spawned Codex subthreads when available.",
      ].join("\n"),
    });
    expect(safeCodexControlRequest).toHaveBeenCalledTimes(1);
    expect(safeCodexControlRequest).toHaveBeenCalledWith(
      undefined,
      CODEX_CONTROL_METHODS.feedback,
      {
        classification: "bug",
        reason: "approved repro",
        threadId: "thread-approved",
        includeLogs: true,
        tags: {
          source: "openclaw-diagnostics",
          channel: "test",
        },
      },
    );
  });

  it("uploads all Codex diagnostics sessions and reports their channel/thread breakdown", async () => {
    const firstSessionFile = path.join(tempDir, "session-one.jsonl");
    const secondSessionFile = path.join(tempDir, "session-two.jsonl");
    await fs.writeFile(
      `${firstSessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-111", cwd: "/repo" }),
    );
    await fs.writeFile(
      `${secondSessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-222", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(async (configForTest, _method, requestParamsLocal) => ({
      ok: true as const,
      value: {
        threadId:
          requestParamsLocal &&
          typeof requestParamsLocal === "object" &&
          "threadId" in requestParamsLocal
            ? requestParamsLocal.threadId
            : undefined,
      },
    }));
    const deps = createDeps({ safeCodexControlRequest });
    const diagnosticsSessions = [
      {
        sessionKey: "agent:main:whatsapp:one",
        sessionId: "session-one",
        sessionFile: firstSessionFile,
        channel: "whatsapp",
      },
      {
        sessionKey: "agent:main:discord:two",
        sessionId: "session-two",
        sessionFile: secondSessionFile,
        channel: "discord",
      },
    ];

    const request = await handleCodexCommand(
      createContext("diagnostics multi-session repro", firstSessionFile, {
        senderId: "user-1",
        channel: "whatsapp",
        sessionKey: "agent:main:whatsapp:one",
        sessionId: "session-one",
        diagnosticsSessions,
      }),
      { deps },
    );
    const token = readDiagnosticsConfirmationToken(request);
    expect(request.text).toContain("Codex runtime threads detected.");
    expect(request.text).toContain("OpenClaw session key: `agent:main:whatsapp:one`");
    expect(request.text).toContain("OpenClaw session id: `session-one`");
    expect(request.text).toContain("Codex thread id: `thread-111`");
    expect(request.text).toContain("OpenClaw session key: `agent:main:discord:two`");
    expect(request.text).toContain("OpenClaw session id: `session-two`");
    expect(request.text).toContain("Codex thread id: `thread-222`");
    expect(safeCodexControlRequest).not.toHaveBeenCalled();

    await expect(
      handleCodexCommand(
        createContext(`diagnostics confirm ${token}`, firstSessionFile, {
          senderId: "user-1",
          channel: "whatsapp",
          sessionKey: "agent:main:whatsapp:one",
          sessionId: "session-one",
          diagnosticsSessions,
        }),
        { deps },
      ),
    ).resolves.toEqual({
      text: [
        "Codex diagnostics sent to OpenAI servers:",
        ...expectedDiagnosticsTargetBlock({
          index: 1,
          channel: "whatsapp",
          sessionKey: "agent:main:whatsapp:one",
          sessionId: "session-one",
          threadId: "thread-111",
        }),
        "",
        ...expectedDiagnosticsTargetBlock({
          index: 2,
          channel: "discord",
          sessionKey: "agent:main:discord:two",
          sessionId: "session-two",
          threadId: "thread-222",
        }),
        "Included Codex logs and spawned Codex subthreads when available.",
      ].join("\n"),
    });
    expect(safeCodexControlRequest).toHaveBeenCalledTimes(2);
    expect(mockArg(safeCodexControlRequest, 0, 0)).toBeUndefined();
    expect(mockArg(safeCodexControlRequest, 0, 1)).toBe(CODEX_CONTROL_METHODS.feedback);
    const firstFeedbackParams = requestParams(safeCodexControlRequest);
    expect(firstFeedbackParams.threadId).toBe("thread-111");
    expect(firstFeedbackParams.includeLogs).toBe(true);
    expect(mockArg(safeCodexControlRequest, 1, 0)).toBeUndefined();
    expect(mockArg(safeCodexControlRequest, 1, 1)).toBe(CODEX_CONTROL_METHODS.feedback);
    const secondFeedbackParams = requestParams(safeCodexControlRequest, 1);
    expect(secondFeedbackParams.threadId).toBe("thread-222");
    expect(secondFeedbackParams.includeLogs).toBe(true);
  });

  it("requires an owner for Codex diagnostics feedback uploads", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-owner", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-owner" },
    }));

    await expect(
      handleCodexCommand(
        createContext("diagnostics", sessionFile, {
          senderIsOwner: false,
        }),
        { deps: createDeps({ safeCodexControlRequest }) },
      ),
    ).resolves.toEqual({
      text: "Only an owner can send Codex diagnostics.",
    });
    expect(safeCodexControlRequest).not.toHaveBeenCalled();
  });

  it("refuses diagnostics confirmations without a stable sender identity", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-sender-required", cwd: "/repo" }),
    );

    await expect(
      handleCodexCommand(
        createContext("diagnostics", sessionFile, {
          senderId: undefined,
        }),
        { deps: createDeps() },
      ),
    ).resolves.toEqual({
      text: "Cannot send Codex diagnostics because this command did not include a sender identity.",
    });
  });

  it("keeps diagnostics confirmation scoped to the requesting sender", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-sender", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-sender" },
    }));
    const deps = createDeps({ safeCodexControlRequest });

    const request = await handleCodexCommand(
      createContext("diagnostics", sessionFile, { senderId: "user-1" }),
      { deps },
    );
    const token = readDiagnosticsConfirmationToken(request);

    await expect(
      handleCodexCommand(
        createContext(`diagnostics confirm ${token}`, sessionFile, { senderId: "user-2" }),
        { deps },
      ),
    ).resolves.toEqual({
      text: "Only the user who requested these Codex diagnostics can confirm the upload.",
    });
    expect(safeCodexControlRequest).not.toHaveBeenCalled();
  });

  it("consumes diagnostics confirmations before async upload work", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    let releaseFirstConfirmBindingRead: () => void = () => undefined;
    let firstConfirmBindingReadStarted: () => void = () => undefined;
    const firstConfirmBindingRead = new Promise<void>((resolve) => {
      releaseFirstConfirmBindingRead = resolve;
    });
    const firstConfirmBindingReadStartedPromise = new Promise<void>((resolve) => {
      firstConfirmBindingReadStarted = resolve;
    });
    let bindingReadCount = 0;
    const readCodexAppServerBinding = vi.fn(async (bindingSessionFile: string) => {
      bindingReadCount += 1;
      if (bindingReadCount === 2) {
        firstConfirmBindingReadStarted();
        await firstConfirmBindingRead;
      }
      return {
        schemaVersion: 2 as const,
        threadId: "thread-race",
        cwd: "/repo",
        sessionFile: bindingSessionFile,
        createdAt: "2026-04-28T00:00:00.000Z",
        updatedAt: "2026-04-28T00:00:00.000Z",
      };
    });
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-race" },
    }));
    const deps = createDeps({ readCodexAppServerBinding, safeCodexControlRequest });

    const request = await handleCodexCommand(
      createContext("diagnostics", sessionFile, { senderId: "user-1" }),
      { deps },
    );
    const token = readDiagnosticsConfirmationToken(request);
    const firstConfirm = handleCodexCommand(
      createContext(`diagnostics confirm ${token}`, sessionFile, { senderId: "user-1" }),
      { deps },
    );
    await firstConfirmBindingReadStartedPromise;

    await expect(
      handleCodexCommand(
        createContext(`diagnostics confirm ${token}`, sessionFile, { senderId: "user-1" }),
        { deps },
      ),
    ).resolves.toEqual({
      text: "No pending Codex diagnostics confirmation was found. Run /diagnostics again to create a fresh request.",
    });

    releaseFirstConfirmBindingRead();
    const firstConfirmResult = await firstConfirm;
    expectResultTextContains(firstConfirmResult, "Codex diagnostics sent to OpenAI servers:");
    expect(safeCodexControlRequest).toHaveBeenCalledTimes(1);
  });

  it("keeps diagnostics confirmation scoped to account and channel identity", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-account", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-account" },
    }));
    const deps = createDeps({ safeCodexControlRequest });

    const request = await handleCodexCommand(
      createContext("diagnostics", sessionFile, {
        accountId: "account-1",
        channelId: "channel-1",
        messageThreadId: "thread-1",
        threadParentId: "parent-1",
        sessionKey: "session-key-1",
      }),
      { deps },
    );
    const token = readDiagnosticsConfirmationToken(request);

    await expect(
      handleCodexCommand(
        createContext(`diagnostics confirm ${token}`, sessionFile, {
          accountId: "account-2",
          channelId: "channel-1",
          messageThreadId: "thread-1",
          threadParentId: "parent-1",
          sessionKey: "session-key-1",
        }),
        { deps },
      ),
    ).resolves.toEqual({
      text: "This Codex diagnostics confirmation belongs to a different account.",
    });
    expect(safeCodexControlRequest).not.toHaveBeenCalled();
  });

  it("allows private-routed diagnostics confirmations from the owner DM", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-private", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(
      async (_pluginConfig: unknown, _method: string, _requestParams: unknown) => ({
        ok: true as const,
        value: { threadId: "thread-private" },
      }),
    );
    const deps = createDeps({ safeCodexControlRequest });

    const request = await handleCodexCommand(
      createContext("diagnostics", sessionFile, {
        accountId: "account-1",
        channelId: "group-channel",
        messageThreadId: "group-topic",
        sessionKey: "group-session",
        diagnosticsPrivateRouted: true,
      }),
      { deps },
    );
    const token = readDiagnosticsConfirmationToken(request);

    await expect(
      handleCodexCommand(
        createContext(`diagnostics confirm ${token}`, undefined, {
          accountId: "account-1",
          channelId: "owner-dm",
          sessionKey: "owner-dm-session",
        }),
        { deps },
      ),
    ).resolves.toEqual({
      text: [
        "Codex diagnostics sent to OpenAI servers:",
        ...expectedDiagnosticsTargetBlock({
          channel: "test",
          sessionKey: "group-session",
          threadId: "thread-private",
        }),
        "Included Codex logs and spawned Codex subthreads when available.",
      ].join("\n"),
    });
    expect(mockArg(safeCodexControlRequest, 0, 0)).toBeUndefined();
    expect(mockArg(safeCodexControlRequest, 0, 1)).toBe(CODEX_CONTROL_METHODS.feedback);
    const feedbackParams = requestParams(safeCodexControlRequest);
    expect(feedbackParams.classification).toBe("bug");
    expect(feedbackParams.threadId).toBe("thread-private");
    expect(feedbackParams.includeLogs).toBe(true);
  });

  it("keeps diagnostics confirmation eviction scoped to account identity", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-confirm-scope", cwd: "/repo" }),
    );

    const firstRequest = await handleCodexCommand(
      createContext("diagnostics", sessionFile, {
        accountId: "account-kept",
        channelId: "channel-kept",
      }),
      { deps: createDeps() },
    );
    const firstToken = readDiagnosticsConfirmationToken(firstRequest);

    for (let index = 0; index < 100; index += 1) {
      await handleCodexCommand(
        createContext(`diagnostics ${index}`, sessionFile, {
          accountId: "account-noisy",
          channelId: "channel-noisy",
        }),
        { deps: createDeps() },
      );
    }

    await expect(
      handleCodexCommand(
        createContext(`diagnostics cancel ${firstToken}`, sessionFile, {
          accountId: "account-kept",
          channelId: "channel-kept",
        }),
        { deps: createDeps() },
      ),
    ).resolves.toEqual({
      text: [
        "Codex diagnostics upload canceled.",
        "Codex sessions:",
        ...expectedDiagnosticsTargetBlock({
          channel: "test",
          threadId: "thread-confirm-scope",
        }),
      ].join("\n"),
    });
  });

  it("bounds diagnostics notes before upload", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-789", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(
      async (_pluginConfig: unknown, _method: string, _requestParams: unknown) => ({
        ok: true as const,
        value: { threadId: "thread-789" },
      }),
    );
    const note = "x".repeat(2050);
    const deps = createDeps({ safeCodexControlRequest });

    const request = await handleCodexCommand(createContext(`diagnostics ${note}`, sessionFile), {
      deps,
    });
    const token = readDiagnosticsConfirmationToken(request);
    await handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps });

    expect(mockArg(safeCodexControlRequest, 0, 0)).toBeUndefined();
    expect(mockArg(safeCodexControlRequest, 0, 1)).toBe(CODEX_CONTROL_METHODS.feedback);
    const feedbackParams = requestParams(safeCodexControlRequest);
    expect(feedbackParams.reason).toBe("x".repeat(2048));
  });

  it("escapes diagnostics notes before showing approval text", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-note", cwd: "/repo" }),
    );

    const request = await handleCodexCommand(
      createContext("diagnostics <@U123> [trusted](https://evil) @here `tick`", sessionFile),
      { deps: createDeps() },
    );

    expect(request.text).toContain(
      "Note: &lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here \uff40tick\uff40",
    );
    expect(request.text).not.toContain("<@U123>");
    expect(request.text).not.toContain("[trusted](https://evil)");
  });

  it("throttles repeated diagnostics uploads for the same thread", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-cooldown", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-cooldown" },
    }));
    const deps = createDeps({ safeCodexControlRequest });

    const request = await handleCodexCommand(createContext("diagnostics first", sessionFile), {
      deps,
    });
    const token = readDiagnosticsConfirmationToken(request);
    await expect(
      handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }),
    ).resolves.toEqual({
      text: [
        "Codex diagnostics sent to OpenAI servers:",
        ...expectedDiagnosticsTargetBlock({
          channel: "test",
          threadId: "thread-cooldown",
        }),
        "Included Codex logs and spawned Codex subthreads when available.",
      ].join("\n"),
    });
    await expect(
      handleCodexCommand(createContext("diagnostics again", sessionFile), { deps }),
    ).resolves.toEqual({
      text: "Codex diagnostics were already sent for thread thread-cooldown recently. Try again in 60s.",
    });
    expect(safeCodexControlRequest).toHaveBeenCalledTimes(1);
  });

  it("throttles diagnostics uploads across threads", async () => {
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: {},
    }));
    const deps = createDeps({ safeCodexControlRequest });
    const sessionFile = path.join(tempDir, "global-cooldown-session.jsonl");

    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-global-1", cwd: "/repo" }),
    );
    const request = await handleCodexCommand(createContext("diagnostics first", sessionFile), {
      deps,
    });
    const token = readDiagnosticsConfirmationToken(request);
    await expect(
      handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }),
    ).resolves.toEqual({
      text: [
        "Codex diagnostics sent to OpenAI servers:",
        ...expectedDiagnosticsTargetBlock({
          channel: "test",
          threadId: "thread-global-1",
        }),
        "Included Codex logs and spawned Codex subthreads when available.",
      ].join("\n"),
    });

    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-global-2", cwd: "/repo" }),
    );
    await expect(
      handleCodexCommand(createContext("diagnostics second", sessionFile), { deps }),
    ).resolves.toEqual({
      text: "Codex diagnostics were already sent for this account or channel recently. Try again in 60s.",
    });

    expect(safeCodexControlRequest).toHaveBeenCalledTimes(1);
  });

  it("does not throttle diagnostics uploads across different account scopes", async () => {
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: {},
    }));
    const deps = createDeps({ safeCodexControlRequest });
    const sessionFile = path.join(tempDir, "scoped-cooldown-session.jsonl");

    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-scope-1", cwd: "/repo" }),
    );
    const firstRequest = await handleCodexCommand(
      createContext("diagnostics first", sessionFile, {
        accountId: "account-1",
        channelId: "channel-1",
      }),
      { deps },
    );
    const firstToken = readDiagnosticsConfirmationToken(firstRequest);
    const firstConfirmResult = await handleCodexCommand(
      createContext(`diagnostics confirm ${firstToken}`, sessionFile, {
        accountId: "account-1",
        channelId: "channel-1",
      }),
      { deps },
    );
    expectResultTextContains(firstConfirmResult, "Codex diagnostics sent to OpenAI servers:");

    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-scope-2", cwd: "/repo" }),
    );
    const secondRequest = await handleCodexCommand(
      createContext("diagnostics second", sessionFile, {
        accountId: "account-2",
        channelId: "channel-2",
      }),
      { deps },
    );
    const secondToken = readDiagnosticsConfirmationToken(secondRequest);
    const secondConfirmResult = await handleCodexCommand(
      createContext(`diagnostics confirm ${secondToken}`, sessionFile, {
        accountId: "account-2",
        channelId: "channel-2",
      }),
      { deps },
    );
    expectResultTextContains(secondConfirmResult, "Codex diagnostics sent to OpenAI servers:");

    expect(safeCodexControlRequest).toHaveBeenCalledTimes(2);
  });

  it("does not collide diagnostics cooldown scopes when ids contain delimiters", async () => {
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: {},
    }));
    const deps = createDeps({ safeCodexControlRequest });
    const sessionFile = path.join(tempDir, "delimiter-cooldown-session.jsonl");

    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-delimiter-1", cwd: "/repo" }),
    );
    const firstScope = {
      accountId: "a",
      channelId: "b",
      channel: "test|channel:x",
    };
    const firstRequest = await handleCodexCommand(
      createContext("diagnostics first", sessionFile, firstScope),
      { deps },
    );
    const firstToken = readDiagnosticsConfirmationToken(firstRequest);
    const firstConfirmResult = await handleCodexCommand(
      createContext(`diagnostics confirm ${firstToken}`, sessionFile, firstScope),
      { deps },
    );
    expectResultTextContains(firstConfirmResult, "Codex diagnostics sent to OpenAI servers:");

    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-delimiter-2", cwd: "/repo" }),
    );
    const secondScope = {
      accountId: "a|channelId:b",
      channel: "test|channel:x",
    };
    const secondRequest = await handleCodexCommand(
      createContext("diagnostics second", sessionFile, secondScope),
      { deps },
    );
    const secondToken = readDiagnosticsConfirmationToken(secondRequest);
    const secondConfirmResult = await handleCodexCommand(
      createContext(`diagnostics confirm ${secondToken}`, sessionFile, secondScope),
      { deps },
    );
    expectResultTextContains(secondConfirmResult, "Codex diagnostics sent to OpenAI servers:");

    expect(safeCodexControlRequest).toHaveBeenCalledTimes(2);
  });

  it("does not collide diagnostics cooldown scopes when long ids share a prefix", async () => {
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: {},
    }));
    const deps = createDeps({ safeCodexControlRequest });
    const sessionFile = path.join(tempDir, "long-scope-cooldown-session.jsonl");
    const sharedPrefix = "account-".repeat(40);

    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-long-scope-1", cwd: "/repo" }),
    );
    const firstScope = {
      accountId: `${sharedPrefix}first`,
      channelId: "channel-long",
    };
    const firstRequest = await handleCodexCommand(
      createContext("diagnostics first", sessionFile, firstScope),
      { deps },
    );
    const firstToken = readDiagnosticsConfirmationToken(firstRequest);
    const firstConfirmResult = await handleCodexCommand(
      createContext(`diagnostics confirm ${firstToken}`, sessionFile, firstScope),
      { deps },
    );
    expectResultTextContains(firstConfirmResult, "Codex diagnostics sent to OpenAI servers:");

    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-long-scope-2", cwd: "/repo" }),
    );
    const secondScope = {
      accountId: `${sharedPrefix}second`,
      channelId: "channel-long",
    };
    const secondRequest = await handleCodexCommand(
      createContext("diagnostics second", sessionFile, secondScope),
      { deps },
    );
    const secondToken = readDiagnosticsConfirmationToken(secondRequest);
    const secondConfirmResult = await handleCodexCommand(
      createContext(`diagnostics confirm ${secondToken}`, sessionFile, secondScope),
      { deps },
    );
    expectResultTextContains(secondConfirmResult, "Codex diagnostics sent to OpenAI servers:");

    expect(safeCodexControlRequest).toHaveBeenCalledTimes(2);
  });

  it("sanitizes diagnostics upload errors before showing them", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "<@U123>", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: false as const,
      error: "bad\n\u009b\u202e <@U123> [trusted](https://evil) @here",
    }));
    const deps = createDeps({ safeCodexControlRequest });

    const request = await handleCodexCommand(createContext("diagnostics", sessionFile), { deps });
    expect(request.text).toContain("Codex thread id: &lt;\uff20U123&gt;");
    expect(request.text).not.toContain("<@U123>");
    const token = readDiagnosticsConfirmationToken(request);
    await expect(
      handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }),
    ).resolves.toEqual({
      text: [
        "Could not send Codex diagnostics:",
        "- channel test, Codex thread &lt;\uff20U123&gt;: bad??? &lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here",
        "Inspect locally:",
        "- run codex resume and paste the thread id shown above",
      ].join("\n"),
    });
  });

  it("does not throttle diagnostics retries after upload failures", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({ schemaVersion: 1, threadId: "thread-retry", cwd: "/repo" }),
    );
    const safeCodexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({ ok: false as const, error: "temporary outage" })
      .mockResolvedValueOnce({ ok: true as const, value: { threadId: "thread-retry" } });
    const deps = createDeps({ safeCodexControlRequest });

    const firstRequest = await handleCodexCommand(createContext("diagnostics", sessionFile), {
      deps,
    });
    const firstToken = readDiagnosticsConfirmationToken(firstRequest);
    await expect(
      handleCodexCommand(createContext(`diagnostics confirm ${firstToken}`, sessionFile), {
        deps,
      }),
    ).resolves.toEqual({
      text: [
        "Could not send Codex diagnostics:",
        "- channel test, Codex thread thread-retry: temporary outage",
        "Inspect locally:",
        "- `codex resume thread-retry`",
      ].join("\n"),
    });

    const secondRequest = await handleCodexCommand(createContext("diagnostics", sessionFile), {
      deps,
    });
    const secondToken = readDiagnosticsConfirmationToken(secondRequest);
    await expect(
      handleCodexCommand(createContext(`diagnostics confirm ${secondToken}`, sessionFile), {
        deps,
      }),
    ).resolves.toEqual({
      text: [
        "Codex diagnostics sent to OpenAI servers:",
        ...expectedDiagnosticsTargetBlock({
          channel: "test",
          threadId: "thread-retry",
        }),
        "Included Codex logs and spawned Codex subthreads when available.",
      ].join("\n"),
    });
    expect(safeCodexControlRequest).toHaveBeenCalledTimes(2);
  });

  it("omits inline diagnostics resume commands for unsafe thread ids", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({
        schemaVersion: 1,
        threadId: "thread-123'`\n\u009b\u202e; echo bad",
        cwd: "/repo",
      }),
    );
    const safeCodexControlRequest = vi.fn(async () => ({
      ok: true as const,
      value: { threadId: "thread-123'`\n\u009b\u202e; echo bad" },
    }));
    const deps = createDeps({ safeCodexControlRequest });

    const request = await handleCodexCommand(createContext("diagnostics", sessionFile), { deps });
    const token = readDiagnosticsConfirmationToken(request);
    await expect(
      handleCodexCommand(createContext(`diagnostics confirm ${token}`, sessionFile), { deps }),
    ).resolves.toEqual({
      text: [
        "Codex diagnostics sent to OpenAI servers:",
        "Session 1",
        "Channel: test",
        "Codex thread id: thread-123'\uff40???; echo bad",
        "Inspect locally: run codex resume and paste the thread id shown above",
        "Included Codex logs and spawned Codex subthreads when available.",
      ].join("\n"),
    });
  });

  it("explains diagnostics when no Codex thread is attached", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");

    await expect(
      handleCodexCommand(createContext("diagnostics", sessionFile), { deps: createDeps() }),
    ).resolves.toEqual({
      text: [
        "No Codex thread is attached to this OpenClaw session yet.",
        "Use /codex threads to find a thread, then /codex resume <thread-id> before sending diagnostics.",
      ].join("\n"),
    });
  });

  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("escapes Codex thread fields and avoids unsafe resume commands", async () => {
    const codexControlRequest = vi.fn(async () => ({
      data: [
        {
          id: "thread-123\n`bad`",
          title: "<@U123> [trusted](https://evil) @here",
          model: "gpt_5",
          cwd: "/repo_(x)",
        },
      ],
    }));
    const deps = createDeps({ codexControlRequest });

    const result = await handleCodexCommand(createContext("threads"), { deps });

    expect(result.text).toContain("thread-123?\uff40bad\uff40");
    expect(result.text).toContain(
      "&lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here",
    );
    expect(result.text).toContain("(gpt\uff3f5, /repo\uff3f\uff08x\uff09)");
    expect(result.text).toContain(
      "Resume: copy the thread id above and run /codex resume <thread-id>",
    );
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
    expect(result.text).not.toContain("Resume: /codex resume thread-123");
  });

  it("escapes Codex MCP and skill list entries before chat display", async () => {
    const codexControlRequest = vi
      .fn()
      .mockResolvedValueOnce({ data: [{ name: "<@U123> [mcp](https://evil)" }] })
      .mockResolvedValueOnce({
        data: [
          {
            cwd: "/repo",
            skills: [
              {
                name: "skill_1 @here",
                description: "",
                path: "/repo/.codex/skills/skill_1/SKILL.md",
                scope: "repo",
                enabled: true,
              },
            ],
            errors: [],
          },
        ],
      });
    const deps = createDeps({ codexControlRequest });

    const mcp = await handleCodexCommand(createContext("mcp"), { deps });
    const skills = await handleCodexCommand(createContext("skills"), { deps });

    expect(mcp.text).toContain("&lt;\uff20U123&gt; \uff3bmcp\uff3d\uff08https://evil\uff09");
    expect(skills.text).toContain("- `skill\uff3f1 \uff20here`");
    expect(`${mcp.text}\n${skills.text}`).not.toContain("<@U123>");
    expect(`${mcp.text}\n${skills.text}`).not.toContain("[mcp](https://evil)");
    expect(`${mcp.text}\n${skills.text}`).not.toContain("@here");
  });

  it("formats every Codex skill as a code-styled bullet and tolerates malformed entries", async () => {
    const malformedSkillEntries: JsonValue[] = [
      null,
      { description: "missing name" },
      {
        name: "final-skill",
        description: "Final skill",
        path: "/repo-b/.codex/skills/final-skill/SKILL.md",
        scope: "repo",
        enabled: true,
      },
    ];
    const codexControlRequest = vi.fn(async () => ({
      data: [
        {
          cwd: "/repo-a",
          skills: Array.from({ length: 26 }, (_, index) => ({
            name: `skill-${index + 1}`,
            description: `Skill ${index + 1}`,
            path: `/repo-a/.codex/skills/skill-${index + 1}/SKILL.md`,
            scope: "repo",
            enabled: true,
          })).concat({
            name: "disabled-skill",
            description: "Disabled skill",
            path: "/repo-a/.codex/skills/disabled-skill/SKILL.md",
            scope: "repo",
            enabled: false,
          }),
          errors: [{ path: "/repo-a/bad/SKILL.md", message: "bad skill" }],
        },
        {
          cwd: "/repo-b",
          skills: malformedSkillEntries,
          errors: [],
        },
        "malformed group",
      ],
    }));
    const deps = createDeps({ codexControlRequest });

    const result = await handleCodexCommand(createContext("skills"), { deps });

    expect(result.text).toContain("- `skill-1`");
    expect(result.text).toContain("- `skill-26`");
    expect(result.text).toContain("- `&lt;unknown&gt;`");
    expect(result.text).toContain("- `final-skill`");
    expect(result.text).not.toContain("Workspace:");
    expect(result.text).not.toContain("Error:");
    expect(result.text).not.toContain("More skills available");
    expect(result.text).not.toContain("Skill 1");
    expect(result.text).not.toContain("/repo-a/.codex/skills");
    expect(result.text).not.toContain("disabled-skill");
  });

  it("reports Codex skill load errors when no skills render", async () => {
    const codexControlRequest = vi.fn(async () => ({
      data: [
        {
          cwd: "/repo-a",
          skills: [
            {
              name: "disabled-skill",
              description: "Disabled skill",
              path: "/repo-a/.codex/skills/disabled-skill/SKILL.md",
              scope: "repo",
              enabled: false,
            },
          ],
          errors: [
            { path: "/repo-a/bad/SKILL.md", message: "bad skill <@U123>" },
            { path: "/repo-a/other/SKILL.md", message: "other bad skill @here" },
          ],
        },
      ],
    }));
    const deps = createDeps({ codexControlRequest });

    const result = await handleCodexCommand(createContext("skills"), { deps });

    expect(result.text).toBe("Codex skills: none returned (2 load errors).");
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("@here");
  });

  it("returns sanitized command failures instead of leaking app-server errors", 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 failure = () => {
      throw new Error("app-server failed <@U123> [trusted](https://evil) @here");
    };
    const expectSanitizedFailure = (result: PluginCommandResult) => {
      expect(result.text).toContain(
        "Codex command failed: app-server failed &lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09 \uff20here",
      );
      expect(result.text).not.toContain("<@U123>");
      expect(result.text).not.toContain("[trusted](https://evil)");
      expect(result.text).not.toContain("@here");
    };

    for (const [args, deps] of [
      ["models", createDeps({ listCodexAppServerModels: vi.fn(failure) })],
      ["threads", createDeps({ codexControlRequest: vi.fn(failure) })],
      ["mcp", createDeps({ codexControlRequest: vi.fn(failure) })],
      ["skills", createDeps({ codexControlRequest: vi.fn(failure) })],
      ["resume thread-123", createDeps({ codexControlRequest: vi.fn(failure) })],
      ["compact", createDeps({ codexControlRequest: vi.fn(failure) })],
      ["review", createDeps({ codexControlRequest: vi.fn(failure) })],
      ["bind", createDeps({ startCodexConversationThread: vi.fn(failure) })],
      ["stop", createDeps({ stopCodexConversationTurn: vi.fn(failure) })],
      ["steer keep going", createDeps({ steerCodexConversationTurn: vi.fn(failure) })],
      ["model gpt-5.4", createDeps({ setCodexConversationModel: vi.fn(failure) })],
    ] as const) {
      expectSanitizedFailure(await handleCodexCommand(createContext(args, sessionFile), { deps }));
    }
  });

  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",
        authProfileId: "openai:work",
        modelProvider: "openai",
      }),
    );
    const startCodexConversationThread = vi.fn(async () => ({
      kind: "codex-app-server-session" as const,
      version: 1 as const,
      sessionFile,
      workspaceDir: "/repo",
    }));
    const requestConversationBinding = vi.fn(async (_request?: { summary?: string }) => ({
      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,
      config: {},
      sessionFile,
      workspaceDir: "/repo",
      agentDir: path.join(tempDir, "agents", "main", "agent"),
      sessionKey: undefined,
      agentId: "main",
      threadId: "thread-123",
      model: "gpt-5.4",
      modelProvider: "openai",
      authProfileId: "openai:work",
    });
    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("binds quoted workspace paths that contain spaces", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const startCodexConversationThread = vi.fn(async () => ({
      kind: "codex-app-server-session" as const,
      version: 1 as const,
      sessionFile,
      workspaceDir: "/repo with space",
    }));
    const requestConversationBinding = vi.fn(async (_request?: { summary?: string }) => ({
      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 with space"', sessionFile, {
          requestConversationBinding,
        }),
        {
          deps: createDeps({
            startCodexConversationThread,
            resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"),
          }),
        },
      ),
    ).resolves.toEqual({
      text: "Bound this conversation to Codex thread thread-123 in /repo with space.",
    });
    expect(startCodexConversationThread).toHaveBeenCalledWith({
      pluginConfig: undefined,
      config: {},
      sessionFile,
      workspaceDir: "/repo with space",
      agentDir: path.join(tempDir, "agents", "main", "agent"),
      sessionKey: undefined,
      agentId: "main",
      threadId: "thread-123",
      model: undefined,
      modelProvider: undefined,
    });
  });

  it("escapes bound Codex thread ids and workspace paths before chat display", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const unsafeThread = "thread-123 <@U123>";
    const unsafeWorkspace = "/repo [trusted](https://evil)";
    const startCodexConversationThread = vi.fn(async () => ({
      kind: "codex-app-server-session" as const,
      version: 1 as const,
      sessionFile,
      workspaceDir: unsafeWorkspace,
    }));
    const requestConversationBinding = vi.fn(async (_request?: { summary?: string }) => ({
      status: "bound" as const,
      binding: {
        bindingId: "binding-1",
        pluginId: "codex",
        pluginRoot: "/plugin",
        channel: "test",
        accountId: "default",
        conversationId: "conversation",
        boundAt: 1,
      },
    }));

    const result = await handleCodexCommand(
      createContext(`bind "${unsafeThread}" --cwd "${unsafeWorkspace}"`, sessionFile, {
        requestConversationBinding,
      }),
      {
        deps: createDeps({
          startCodexConversationThread,
          resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"),
        }),
      },
    );

    expect(result.text).toContain("thread-123 &lt;\uff20U123&gt;");
    expect(result.text).toContain("/repo \uff3btrusted\uff3d\uff08https://evil\uff09");
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
    const bindingRequest = mockArg(requestConversationBinding, 0, 0) as { summary?: string };
    expect(bindingRequest?.summary).toBe(
      "Codex app-server thread thread-123 &lt;\uff20U123&gt; in /repo \uff3btrusted\uff3d\uff08https://evil\uff09",
    );
  });

  it("rejects bind options with missing, blank, or repeated values before starting Codex", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const startCodexConversationThread = vi.fn();
    const requestConversationBinding = vi.fn();

    await expect(
      handleCodexCommand(
        createContext("bind thread-123 --cwd --model gpt-5.4", sessionFile, {
          requestConversationBinding,
        }),
        {
          deps: createDeps({
            startCodexConversationThread,
            resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"),
          }),
        },
      ),
    ).resolves.toEqual({
      text: "Usage: /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
    });
    await expect(
      handleCodexCommand(
        createContext('bind thread-123 --cwd ""', sessionFile, {
          requestConversationBinding,
        }),
        {
          deps: createDeps({
            startCodexConversationThread,
            resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"),
          }),
        },
      ),
    ).resolves.toEqual({
      text: "Usage: /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
    });
    await expect(
      handleCodexCommand(
        createContext("bind thread-123 --cwd /repo --cwd /other", sessionFile, {
          requestConversationBinding,
        }),
        {
          deps: createDeps({
            startCodexConversationThread,
            resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"),
          }),
        },
      ),
    ).resolves.toEqual({
      text: "Usage: /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
    });
    expect(startCodexConversationThread).not.toHaveBeenCalled();
    expect(requestConversationBinding).not.toHaveBeenCalled();
  });

  it("rejects malformed bind arguments before requiring a session file", async () => {
    const startCodexConversationThread = vi.fn();

    await expect(
      handleCodexCommand(createContext("bind thread-123 --cwd", undefined), {
        deps: createDeps({
          startCodexConversationThread,
          resolveCodexDefaultWorkspaceDir: vi.fn(() => "/default"),
        }),
      }),
    ).resolves.toEqual({
      text: "Usage: /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
    });
    expect(startCodexConversationThread).not.toHaveBeenCalled();
  });

  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 <@U123> [trusted](https://evil)",
          }),
        }),
        {
          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 &lt;\uff20U123&gt; \uff3btrusted\uff3d\uff08https://evil\uff09",
    });
    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("rejects malformed detach commands before clearing bindings", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const clearCodexAppServerBinding = vi.fn();
    const detachConversationBinding = vi.fn();

    await expect(
      handleCodexCommand(
        createContext("detach now", sessionFile, {
          detachConversationBinding,
        }),
        { deps: createDeps({ clearCodexAppServerBinding }) },
      ),
    ).resolves.toEqual({
      text: "Usage: /codex detach",
    });
    expect(detachConversationBinding).not.toHaveBeenCalled();
    expect(clearCodexAppServerBinding).not.toHaveBeenCalled();
  });

  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,
      agentDir: path.join(tempDir, "agents", "main", "agent"),
      config: {},
    });
  });

  it("stops the active bound Codex turn in sandboxed sessions", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const stopCodexConversationTurn = vi.fn(async () => ({
      stopped: true,
      message: "Codex stop requested.",
    }));

    await expect(
      handleCodexCommand(createSandboxedContext("stop", sessionFile), {
        deps: createDeps({ stopCodexConversationTurn }),
      }),
    ).resolves.toEqual({ text: "Codex stop requested." });
    expect(stopCodexConversationTurn).toHaveBeenCalledWith({
      sessionFile,
      pluginConfig: undefined,
      agentDir: path.join(tempDir, "agents", "main", "agent"),
      config: { agents: { defaults: { sandbox: { mode: "all" } } } },
    });
  });

  it("rejects malformed stop commands before interrupting Codex", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const stopCodexConversationTurn = vi.fn();

    await expect(
      handleCodexCommand(createContext("stop now", sessionFile), {
        deps: createDeps({ stopCodexConversationTurn }),
      }),
    ).resolves.toEqual({ text: "Usage: /codex stop" });
    expect(stopCodexConversationTurn).not.toHaveBeenCalled();
  });

  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",
      agentDir: path.join(tempDir, "agents", "main", "agent"),
      config: {},
    });
  });

  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",
      agentDir: path.join(tempDir, "agents", "main", "agent"),
      config: {},
    });
    expect(setCodexConversationFastMode).toHaveBeenCalledWith({
      sessionFile,
      pluginConfig: undefined,
      enabled: true,
      agentDir: path.join(tempDir, "agents", "main", "agent"),
      config: {},
    });
    expect(setCodexConversationPermissions).toHaveBeenCalledWith({
      sessionFile,
      pluginConfig: undefined,
      mode: "yolo",
      agentDir: path.join(tempDir, "agents", "main", "agent"),
      config: {},
    });
  });

  it("requires an owner or operator.admin for Codex binding and permission changes", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const startCodexConversationThread = vi.fn();
    const codexControlRequest = vi.fn();
    const resolveCodexCliSessionForBindingOnNode = vi.fn();
    const stopCodexConversationTurn = vi.fn();
    const steerCodexConversationTurn = vi.fn();
    const setCodexConversationModel = vi.fn();
    const setCodexConversationFastMode = vi.fn();
    const setCodexConversationPermissions = vi.fn(
      async () => "Codex permissions set to full access.",
    );
    const cases = [
      ["bind", createDeps({ startCodexConversationThread }), startCodexConversationThread],
      ["resume thread-123", createDeps({ codexControlRequest }), codexControlRequest],
      [
        "resume cli-session --host worker-1 --bind here",
        createDeps({ resolveCodexCliSessionForBindingOnNode }),
        resolveCodexCliSessionForBindingOnNode,
      ],
      ["stop", createDeps({ stopCodexConversationTurn }), stopCodexConversationTurn],
      [
        "permissions yolo",
        createDeps({ setCodexConversationPermissions }),
        setCodexConversationPermissions,
      ],
      ["steer continue", createDeps({ steerCodexConversationTurn }), steerCodexConversationTurn],
      ["model gpt-5.5", createDeps({ setCodexConversationModel }), setCodexConversationModel],
      ["fast on", createDeps({ setCodexConversationFastMode }), setCodexConversationFastMode],
      ["compact", createDeps({ codexControlRequest }), codexControlRequest],
      ["review", createDeps({ codexControlRequest }), codexControlRequest],
    ] as const;

    for (const [args, deps, sideEffect] of cases) {
      await expect(
        handleCodexCommand(
          createContext(args, sessionFile, {
            senderIsOwner: false,
            gatewayClientScopes: ["operator.write"],
          }),
          { deps },
        ),
      ).resolves.toEqual({
        text: "Only an owner or operator.admin can control Codex native execution.",
      });
      expect(sideEffect).not.toHaveBeenCalled();
    }

    const detachConversationBinding = vi.fn();
    for (const args of ["detach", "unbind"]) {
      await expect(
        handleCodexCommand(
          createContext(args, sessionFile, {
            senderIsOwner: false,
            gatewayClientScopes: ["operator.write"],
            detachConversationBinding,
          }),
          { deps: createDeps() },
        ),
      ).resolves.toEqual({
        text: "Only an owner or operator.admin can control Codex native execution.",
      });
    }
    expect(detachConversationBinding).not.toHaveBeenCalled();

    const readCodexPermissions = vi.fn(async () => "Codex permissions: full access.");
    await expect(
      handleCodexCommand(
        createContext("permissions status", sessionFile, {
          senderIsOwner: false,
          gatewayClientScopes: ["operator.write"],
        }),
        { deps: createDeps({ setCodexConversationPermissions: readCodexPermissions }) },
      ),
    ).resolves.toEqual({ text: "Codex permissions: full access." });
    expect(readCodexPermissions).toHaveBeenCalledTimes(1);

    await expect(
      handleCodexCommand(
        createContext("permissions yolo", sessionFile, {
          senderIsOwner: true,
          gatewayClientScopes: ["operator.write"],
        }),
        { deps: createDeps({ setCodexConversationPermissions }) },
      ),
    ).resolves.toEqual({ text: "Codex permissions set to full access." });
    expect(setCodexConversationPermissions).toHaveBeenCalledTimes(1);

    await expect(
      handleCodexCommand(
        createContext("permissions yolo", sessionFile, {
          senderIsOwner: false,
          gatewayClientScopes: ["operator.admin"],
        }),
        { deps: createDeps({ setCodexConversationPermissions }) },
      ),
    ).resolves.toEqual({ text: "Codex permissions set to full access." });
    expect(setCodexConversationPermissions).toHaveBeenCalledTimes(2);
  });

  it("escapes current bound model status before chat display", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({
        schemaVersion: 1,
        threadId: "thread-model",
        cwd: "/repo",
        model: "model_<@U123>_[trusted](https://evil)",
      }),
    );

    const result = await handleCodexCommand(createContext("model", sessionFile), {
      deps: createDeps(),
    });

    expect(result.text).toContain(
      "model\uff3f&lt;\uff20U123&gt;\uff3f\uff3btrusted\uff3d\uff08https://evil\uff09",
    );
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
  });

  it("rejects malformed model commands before persisting the model", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const setCodexConversationModel = vi.fn();

    await expect(
      handleCodexCommand(createContext("model gpt-5.4 extra", sessionFile), {
        deps: createDeps({ setCodexConversationModel }),
      }),
    ).resolves.toEqual({ text: "Usage: /codex model <model>" });
    expect(setCodexConversationModel).not.toHaveBeenCalled();
  });

  it("rejects extra fast and permissions arguments", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    const setCodexConversationFastMode = vi.fn();
    const setCodexConversationPermissions = vi.fn();
    const deps = createDeps({
      setCodexConversationFastMode,
      setCodexConversationPermissions,
    });

    await expect(
      handleCodexCommand(createContext("fast on now", sessionFile), { deps }),
    ).resolves.toEqual({ text: "Usage: /codex fast [on|off|status]" });
    await expect(
      handleCodexCommand(createContext("permissions yolo now", sessionFile), { deps }),
    ).resolves.toEqual({ text: "Usage: /codex permissions [default|yolo|status]" });

    expect(setCodexConversationFastMode).not.toHaveBeenCalled();
    expect(setCodexConversationPermissions).not.toHaveBeenCalled();
  });

  it("rejects malformed control arguments before requiring a session file", async () => {
    const deps = createDeps({
      setCodexConversationModel: vi.fn(),
      setCodexConversationFastMode: vi.fn(),
      setCodexConversationPermissions: vi.fn(),
    });

    await expect(
      handleCodexCommand(createContext("model gpt-5.4 extra"), { deps }),
    ).resolves.toEqual({
      text: "Usage: /codex model <model>",
    });
    await expect(handleCodexCommand(createContext("fast on now"), { deps })).resolves.toEqual({
      text: "Usage: /codex fast [on|off|status]",
    });
    await expect(
      handleCodexCommand(createContext("permissions yolo now"), { deps }),
    ).resolves.toEqual({
      text: "Usage: /codex permissions [default|yolo|status]",
    });
    expect(deps.setCodexConversationModel).not.toHaveBeenCalled();
    expect(deps.setCodexConversationFastMode).not.toHaveBeenCalled();
    expect(deps.setCodexConversationPermissions).not.toHaveBeenCalled();
  });

  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,
      agentDir: path.join(tempDir, "agents", "main", "agent"),
      config: {},
    });
  });

  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.replaceAll("_", "\uff3f")}`,
      ].join("\n"),
    });
  });

  it("escapes active binding fields before chat display", async () => {
    const sessionFile = path.join(tempDir, "session.jsonl");
    await fs.writeFile(
      `${sessionFile}.codex-app-server.json`,
      JSON.stringify({
        schemaVersion: 1,
        threadId: "thread-123 <@U123>",
        cwd: "/repo",
        model: "gpt [trusted](https://evil)",
      }),
    );

    const result = await 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 <@U123>",
          },
        }),
      }),
      { deps: createDeps() },
    );

    expect(result.text).toContain("Thread: thread-123 &lt;\uff20U123&gt;");
    expect(result.text).toContain("Workspace: /repo &lt;\uff20U123&gt;");
    expect(result.text).toContain("Model: gpt \uff3btrusted\uff3d\uff08https://evil\uff09");
    expect(result.text).not.toContain("<@U123>");
    expect(result.text).not.toContain("[trusted](https://evil)");
  });
});

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.",
  };
}
