diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 9f601b5fc..287d6a6e0 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -80,6 +80,7 @@ Compatibility forms are supported for migrations and wrapper-routed environments | `codex-multi-auth models` | Inspect local model/account capability views | | `codex-multi-auth monitor` | Aggregate runtime, usage, policy, quota, model, and project state | | `codex-multi-auth why-selected [--now|--last]` | Explain which account the selector picks now or via the last persisted runtime snapshot | +| `codex-multi-auth history [list\|show ]` | List every local Codex session across all providers, bypassing the `model_provider` filtering that hides threads in `codex resume` while runtime rotation / app bind is active | | `codex-multi-auth rotation enable\|disable\|status\|bind-app\|unbind-app` | Manage the default-on runtime Responses proxy for live Codex account rotation | --- @@ -90,7 +91,7 @@ Compatibility forms are supported for migrations and wrapper-routed environments | --- | --- | --- | | `--device-auth` | login | Use the OpenAI Codex device-code flow for remote/headless login (mutually exclusive with `--manual` / `--no-browser`) | | `--manual`, `--no-browser` | login | Skip browser launch and use manual callback flow (mutually exclusive with `--device-auth`) | -| `--json` | verify-flagged, verify, why-selected, best, forecast, report, usage, budget, models, monitor, integrations, fix, doctor, config explain, debug bundle | Print machine-readable output | +| `--json` | verify-flagged, verify, why-selected, best, forecast, report, usage, budget, models, monitor, integrations, fix, doctor, config explain, debug bundle, history | Print machine-readable output | | `--csv` | usage | Print or write CSV bucket output | | `--explain` | forecast, report | Include reasoning details (forecast text/JSON, report text) | | `--live` | best, forecast, report, fix | Use live probe before decisions/output | @@ -227,6 +228,35 @@ Generated snippets use `CODEX_MULTI_AUTH_LOCAL_KEY`. The Python snippet uses --- +## `codex-multi-auth history` + +Lists local Codex sessions by reading the rollout files under +`/sessions` (default `~/.codex/sessions`, honoring `CODEX_HOME`) +directly. Codex's own `codex resume` view filters threads by the `model_provider` +recorded in each session; while runtime rotation or app bind is active that +provider is `codex-multi-auth-runtime-proxy`, so sessions created under the +native `openai` provider (or vice versa) are hidden from `resume` even though +the files are still present. This command shows every session regardless of +provider, which is the fix for "history not shared across accounts" reports — +the split is by provider name, not by account. + +Usage: + +```bash +codex-multi-auth history [list] [--json] +codex-multi-auth history show [--json] +``` + +`list` (the default when no subcommand is given) prints each session's +`updated_at`, `model_provider`, id, thread name, and cwd, most-recent first. +`show ` prints the provider/originator metadata and the first few user +messages for a single session. Reopen any session with `codex resume `. + +This command is read-only, performs no network calls, and never mutates Codex or +multi-auth state. + +--- + ## `codex-multi-auth why-selected` Explains which account the rotation selector would pick right now, with diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 542e4dcdf..ee805029e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -79,7 +79,7 @@ The package does not publish a global `codex` binary. `codex-multi-auth ...` is | Accounts progressively lose OAuth tokens while the proxy is active | Rapid account rotation triggers OpenAI's anti-abuse detection, which invalidates tokens in sequence | The proxy detects explicit token-invalidation responses and stops rotating; re-login any invalidated accounts and ensure `minRotationIntervalMs` is at least `60000` (default) | | Microsoft/Outlook SSO account gets invalidated on every first request through the proxy | Microsoft OAuth tokens may be invalidated when the proxy presents them from a different IP or device context than where they were issued | The proxy now detects invalidation at both the upstream request and the token-refresh stage; if the problem persists, set `CODEX_AUTH_TOKEN_INVALIDATION_COOLDOWN_MS=600000` (10 min) and re-login, or keep the Microsoft account disabled from the rotation pool via `codex-multi-auth rotation status` | | Packaged app still uses normal Codex routing | App bind was not installed or was removed | Run `codex-multi-auth rotation bind-app`, then reopen the app | -| Codex Desktop history disappears after app bind | Current Codex Desktop builds can filter local threads by the active provider, and app bind switches the real config to `codex-multi-auth-runtime-proxy` | The data is normally still under `~/.codex`; run `codex-multi-auth rotation unbind-app` or `codex-multi-auth rotation disable` to restore the original provider/config before browsing old history | +| Codex history disappears after app bind, or `/resume` shows only some sessions | Current Codex Desktop and CLI builds filter local threads by the active `model_provider`; app bind / runtime rotation switch the real config to `codex-multi-auth-runtime-proxy`, so threads recorded under the native `openai` provider (or vice versa) are hidden. The split is by provider name, not by account — sessions are not actually scattered per account | The rollout files are all still under `~/.codex/sessions`. Run `codex-multi-auth history` to list every local session across all providers (and `codex-multi-auth history show ` for details), then `codex resume ` to reopen one. To restore the native `/resume` view, run `codex-multi-auth rotation unbind-app` or `codex-multi-auth rotation disable` | | Model speed controls are not visible with rotation | Speed/reasoning controls remain owned by Codex config or CLI flags; the app bind only routes Responses traffic | Set `model_reasoning_effort` in `~/.codex/config.toml` or pass `-c model_reasoning_effort=` for wrapper-launched CLI sessions | | App bind needs to be removed | You want the official app config restored | Run `codex-multi-auth rotation unbind-app` or `codex-multi-auth rotation disable` | diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 64fe15a82..fc84182f3 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -78,6 +78,7 @@ import { } from "./codex-manager/commands/status.js"; import { loadPersistedRuntimeObservabilitySnapshot } from "./runtime/runtime-observability.js"; import { runSwitchCommand } from "./codex-manager/commands/switch.js"; +import { runHistoryCommand } from "./codex-manager/commands/history.js"; import { runUnpinCommand } from "./codex-manager/commands/unpin.js"; import { runWorkspaceCommand } from "./codex-manager/commands/workspace.js"; import { runUsageCommand } from "./codex-manager/commands/usage.js"; @@ -591,6 +592,7 @@ const CLI_COMMAND_HANDLERS: ReadonlyMap = new Map< sanitizeEmail, }), ], + ["history", (rest) => runHistoryCommand(rest)], [ "verify", (rest) => diff --git a/lib/codex-manager/account-manager-commands.ts b/lib/codex-manager/account-manager-commands.ts index 5c6963379..fc91916cf 100644 --- a/lib/codex-manager/account-manager-commands.ts +++ b/lib/codex-manager/account-manager-commands.ts @@ -36,6 +36,7 @@ export const ACCOUNT_MANAGER_COMMANDS = new Set([ "monitor", "rotation", "why-selected", + "history", "config", "init-config", "debug", diff --git a/lib/codex-manager/commands/history.ts b/lib/codex-manager/commands/history.ts new file mode 100644 index 000000000..5eb77a8f2 --- /dev/null +++ b/lib/codex-manager/commands/history.ts @@ -0,0 +1,417 @@ +import { readdirSync, readFileSync, statSync, type Dirent } from "node:fs"; +import { join } from "node:path"; +import { getCodexHomeDir } from "../../runtime-paths.js"; + +/** + * `codex-multi-auth history` — provider-agnostic local session browser. + * + * Codex CLI's `/resume` view filters local rollout threads by the model + * provider that is currently active in `config.toml`. When runtime rotation / + * app bind is enabled the active provider becomes + * `codex-multi-auth-runtime-proxy`, so sessions recorded under the native + * `openai` provider (or vice versa) become invisible in `/resume` even though + * the rollout files still live side-by-side under `~/.codex/sessions/`. Users + * perceive this as "history is not shared across accounts" (issue #612), but + * the split is by provider name, not by account. + * + * This command reads the rollout files directly and lists every local session + * regardless of the provider it was created under, giving a complete view that + * `/resume` cannot. It performs no network calls and never mutates state. + */ + +const ROLLOUT_FILENAME_PATTERN = + /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; + +const DEFAULT_PREVIEW_MESSAGE_COUNT = 3; +const MAX_THREAD_NAME_LENGTH = 80; + +export interface HistorySessionSummary { + id: string; + threadName: string; + updatedAt: string; + provider: string | null; + originator: string | null; + cwd: string | null; + path: string; +} + +export interface HistorySessionDetail extends HistorySessionSummary { + cliVersion: string | null; + messages: string[]; +} + +export interface HistoryCommandDeps { + getCodexHome?: () => string; + readDirRecursive?: (dir: string) => string[]; + readFile?: (path: string) => string; + statMtime?: (path: string) => Date; + logInfo?: (message: string) => void; + logError?: (message: string) => void; +} + +interface JsonRecord { + type?: unknown; + timestamp?: unknown; + payload?: Record; +} + +function readStringField( + record: Record | undefined, + key: string, +): string | null { + const value = record?.[key]; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function defaultReadDirRecursive(dir: string): string[] { + const results: string[] = []; + let entries: Dirent[] = []; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return results; + } + for (const entry of entries) { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...defaultReadDirRecursive(entryPath)); + continue; + } + if (entry.isFile() && ROLLOUT_FILENAME_PATTERN.test(entry.name)) { + results.push(entryPath); + } + } + return results; +} + +function extractIdFromFilename(fileName: string): string | null { + const match = fileName.match(ROLLOUT_FILENAME_PATTERN); + return match?.[1] ?? null; +} + +/** + * Separator-agnostic basename. Rollout paths may carry Windows separators even + * when this runs on POSIX (e.g. injected paths in tests, or a CODEX_HOME on a + * mounted Windows volume), so split on both `/` and `\` rather than relying on + * the platform-specific node:path basename. + */ +function baseNameOf(filePath: string): string { + const segments = filePath.split(/[\\/]/); + return segments[segments.length - 1] ?? filePath; +} + +/** + * Parse a single rollout file into a session summary. + * + * Mirrors the index-building logic the wrapper uses (scripts/codex.js + * parseRolloutIndexEntry) but additionally surfaces the `model_provider` and + * `originator` from `session_meta`, which is the field that actually drives the + * `/resume` visibility split. Returns null for files without a `session_meta` + * record or that cannot be read, so partially-written rollouts are skipped + * rather than crashing the listing. + */ +function parseRollout( + rolloutPath: string, + deps: Required>, + previewLimit: number, +): HistorySessionDetail | null { + const idFromName = extractIdFromFilename(baseNameOf(rolloutPath)); + if (!idFromName) return null; + + let content: string; + try { + content = deps.readFile(rolloutPath); + } catch { + return null; + } + + const lines = content.split(/\r?\n/); + let id = idFromName; + let threadName = ""; + let updatedAt: string | null = null; + let provider: string | null = null; + let originator: string | null = null; + let cwd: string | null = null; + let cliVersion: string | null = null; + let hasSessionMeta = false; + const messages: string[] = []; + + for (const line of lines) { + if (line.trim().length === 0) continue; + let record: JsonRecord; + try { + record = JSON.parse(line) as JsonRecord; + } catch { + // Tolerate malformed/partial lines; a single bad line must not drop + // the whole session from the listing. + continue; + } + + if (typeof record.timestamp === "string") { + updatedAt = record.timestamp; + } + + if (record.type === "session_meta") { + const payload = record.payload; + const metaId = readStringField(payload, "id"); + if (metaId) id = metaId; + provider = readStringField(payload, "model_provider") ?? provider; + originator = readStringField(payload, "originator") ?? originator; + cwd = readStringField(payload, "cwd") ?? cwd; + cliVersion = readStringField(payload, "cli_version") ?? cliVersion; + hasSessionMeta = true; + } + + if (record.type === "event_msg") { + const payload = record.payload; + const payloadType = readStringField(payload, "type"); + if (!threadName && payloadType) { + const message = readStringField(payload, "message"); + if (message) { + threadName = message; + } + } + if ( + payloadType === "user_message" && + messages.length < previewLimit + ) { + const message = readStringField(payload, "message"); + if (message) { + messages.push(message); + } + } + } + } + + if (!hasSessionMeta) { + return null; + } + + if (!updatedAt) { + try { + updatedAt = deps.statMtime(rolloutPath).toISOString(); + } catch { + updatedAt = new Date().toISOString(); + } + } + + if (!threadName) { + threadName = "Codex session"; + } + if (threadName.length > MAX_THREAD_NAME_LENGTH) { + threadName = `${threadName.slice(0, MAX_THREAD_NAME_LENGTH - 3)}...`; + } + + return { + id, + threadName, + updatedAt, + provider, + originator, + cwd, + cliVersion, + messages, + path: rolloutPath, + }; +} + +function resolveDeps(deps: HistoryCommandDeps): { + getCodexHome: () => string; + readDirRecursive: (dir: string) => string[]; + readFile: (path: string) => string; + statMtime: (path: string) => Date; + logInfo: (message: string) => void; + logError: (message: string) => void; +} { + return { + getCodexHome: deps.getCodexHome ?? getCodexHomeDir, + readDirRecursive: deps.readDirRecursive ?? defaultReadDirRecursive, + readFile: deps.readFile ?? ((path) => readFileSync(path, "utf8")), + statMtime: deps.statMtime ?? ((path) => statSync(path).mtime), + logInfo: deps.logInfo ?? ((message) => console.log(message)), + logError: deps.logError ?? ((message) => console.error(message)), + }; +} + +function collectSessions( + resolved: ReturnType, + previewLimit: number, +): HistorySessionDetail[] { + const sessionsDir = join(resolved.getCodexHome(), "sessions"); + let files: string[]; + try { + files = resolved.readDirRecursive(sessionsDir); + } catch { + // A missing or unreadable sessions directory is a normal "no history yet" + // state, not an error — return an empty listing rather than crashing. + files = []; + } + const sessions: HistorySessionDetail[] = []; + for (const file of files) { + const parsed = parseRollout( + file, + { readFile: resolved.readFile, statMtime: resolved.statMtime }, + previewLimit, + ); + if (parsed) sessions.push(parsed); + } + // Most-recent first, matching how /resume presents threads. + sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return sessions; +} + +function toSummary(detail: HistorySessionDetail): HistorySessionSummary { + const { cliVersion: _cliVersion, messages: _messages, ...summary } = detail; + void _cliVersion; + void _messages; + return summary; +} + +function runHistoryList( + args: string[], + resolved: ReturnType, +): number { + const json = args.includes("--json") || args.includes("-j"); + const sessions = collectSessions(resolved, DEFAULT_PREVIEW_MESSAGE_COUNT); + + if (json) { + resolved.logInfo( + JSON.stringify( + { count: sessions.length, sessions: sessions.map(toSummary) }, + null, + 2, + ), + ); + return 0; + } + + if (sessions.length === 0) { + resolved.logInfo( + `No local Codex sessions found under ${join(resolved.getCodexHome(), "sessions")}.`, + ); + return 0; + } + + resolved.logInfo( + `Local Codex sessions (${sessions.length}) — all providers, bypassing /resume provider filtering:`, + ); + resolved.logInfo(""); + for (const session of sessions) { + const provider = session.provider ?? "unknown-provider"; + resolved.logInfo(` ${session.updatedAt} [${provider}]`); + resolved.logInfo(` id: ${session.id}`); + resolved.logInfo(` thread: ${session.threadName}`); + if (session.cwd) { + resolved.logInfo(` cwd: ${session.cwd}`); + } + } + resolved.logInfo(""); + resolved.logInfo( + "Resume any session with: codex resume (or `codex resume` for the picker).", + ); + return 0; +} + +function runHistoryShow( + args: string[], + resolved: ReturnType, +): number { + const json = args.includes("--json") || args.includes("-j"); + const sessionId = args.find((arg) => !arg.startsWith("-")); + if (!sessionId) { + resolved.logError( + "Missing session id. Usage: codex-multi-auth history show [--json]", + ); + return 1; + } + + const sessions = collectSessions(resolved, DEFAULT_PREVIEW_MESSAGE_COUNT); + const match = sessions.find((session) => session.id === sessionId); + if (!match) { + resolved.logError(`Session not found: ${sessionId}`); + return 1; + } + + if (json) { + resolved.logInfo(JSON.stringify(match, null, 2)); + return 0; + } + + resolved.logInfo(`Session ${match.id}`); + resolved.logInfo(` provider: ${match.provider ?? "unknown"}`); + resolved.logInfo(` originator: ${match.originator ?? "unknown"}`); + resolved.logInfo(` updated: ${match.updatedAt}`); + if (match.cliVersion) { + resolved.logInfo(` cli: ${match.cliVersion}`); + } + if (match.cwd) { + resolved.logInfo(` cwd: ${match.cwd}`); + } + resolved.logInfo(` file: ${match.path}`); + resolved.logInfo(""); + if (match.messages.length === 0) { + resolved.logInfo(" (no user messages recorded)"); + } else { + resolved.logInfo(" First messages:"); + for (const message of match.messages) { + const firstLine = message.split(/\r?\n/, 1)[0] ?? ""; + const preview = + firstLine.length > 120 ? `${firstLine.slice(0, 117)}...` : firstLine; + resolved.logInfo(` - ${preview}`); + } + } + resolved.logInfo(""); + resolved.logInfo(`Resume with: codex resume ${match.id}`); + return 0; +} + +function printHistoryUsage(resolved: ReturnType): void { + resolved.logInfo( + [ + "Usage: codex-multi-auth history [options]", + "", + " list [--json] List every local session across all providers", + " show [--json] Show provider metadata and first messages for a session", + "", + "Lists rollout files under /sessions (default", + "~/.codex/sessions, honoring CODEX_HOME) directly, so sessions", + "created under a different model provider (e.g. while runtime rotation", + "or app bind is active) remain visible even when `codex resume` hides", + "them. See docs/troubleshooting.md for background.", + ].join("\n"), + ); +} + +export function runHistoryCommand( + args: string[], + deps: HistoryCommandDeps = {}, +): number { + const resolved = resolveDeps(deps); + const [subcommand, ...rest] = args; + + if (subcommand === "--help" || subcommand === "-h") { + printHistoryUsage(resolved); + return 0; + } + + // Default to `list` when no subcommand is given, or when the first arg is a + // flag (e.g. `history --json`), so the documented `[list] [--json]` form + // works without an explicit `list`. A leading flag is forwarded as a list + // argument rather than mistaken for a subcommand. + if (!subcommand || subcommand === "list") { + return runHistoryList(rest, resolved); + } + if (subcommand.startsWith("-")) { + return runHistoryList([subcommand, ...rest], resolved); + } + + if (subcommand === "show") { + return runHistoryShow(rest, resolved); + } + + resolved.logError(`Unknown history command: ${subcommand}`); + printHistoryUsage(resolved); + return 1; +} diff --git a/lib/codex-manager/help.ts b/lib/codex-manager/help.ts index f4eadf6da..3174dbdc2 100644 --- a/lib/codex-manager/help.ts +++ b/lib/codex-manager/help.ts @@ -38,6 +38,7 @@ export function printUsage(): void { " codex-multi-auth rotation ", " codex-multi-auth rotation reset-rate-limits [--all | --account ] [--dry-run] [--json]", " codex-multi-auth why-selected [--now | --last] [--json]", + " codex-multi-auth history [list|show ] [--json] (all local sessions, ignores /resume provider filtering)", "", "Advanced:", " codex-multi-auth report [--live] [--json] [--explain] [--model ] [--out ]", diff --git a/scripts/codex-routing.js b/scripts/codex-routing.js index 50c763841..3dd65a17a 100644 --- a/scripts/codex-routing.js +++ b/scripts/codex-routing.js @@ -24,6 +24,7 @@ const AUTH_SUBCOMMANDS = new Set([ "monitor", "rotation", "why-selected", + "history", "config", "init-config", "debug", diff --git a/test/codex-manager-history-command.test.ts b/test/codex-manager-history-command.test.ts new file mode 100644 index 000000000..3adb3cb65 --- /dev/null +++ b/test/codex-manager-history-command.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runHistoryCommand, + type HistoryCommandDeps, +} from "../lib/codex-manager/commands/history.js"; + +const DIR = "/home/user/.codex/sessions"; + +function metaLine( + overrides: Record = {}, + timestamp = "2026-06-05T14:36:20.000Z", +): string { + return JSON.stringify({ + timestamp, + type: "session_meta", + payload: { + id: "019e9836-5001-7821-a9c2-3ffd26a1199b", + timestamp, + cwd: "C:\\work\\project", + originator: "Codex Desktop", + cli_version: "0.140.0", + model_provider: "openai", + ...overrides, + }, + }); +} + +function userMessageLine(message: string): string { + return JSON.stringify({ + timestamp: "2026-06-05T14:36:25.000Z", + type: "event_msg", + payload: { type: "user_message", message }, + }); +} + +interface FakeFile { + id: string; + content: string; +} + +function rolloutPath(id: string): string { + return `${DIR}/2026/06/05/rollout-2026-06-05T22-35-56-${id}.jsonl`; +} + +function createDeps( + files: FakeFile[], + overrides: Partial = {}, +): HistoryCommandDeps & { + logInfo: ReturnType; + logError: ReturnType; +} { + const byPath = new Map(); + for (const file of files) { + byPath.set(rolloutPath(file.id), file.content); + } + const logInfo = vi.fn(); + const logError = vi.fn(); + return { + getCodexHome: () => "/home/user/.codex", + readDirRecursive: () => [...byPath.keys()], + readFile: (path: string) => { + const content = byPath.get(path); + if (content === undefined) { + throw new Error(`ENOENT: ${path}`); + } + return content; + }, + statMtime: () => new Date("2026-01-01T00:00:00.000Z"), + logInfo, + logError, + ...overrides, + }; +} + +function allOutput(logInfo: ReturnType): string { + return logInfo.mock.calls.map((call) => String(call[0])).join("\n"); +} + +describe("runHistoryCommand list", () => { + it("lists sessions from every provider, not just the active one", () => { + const deps = createDeps([ + { + id: "019e9836-5001-7821-a9c2-3ffd26a1199b", + content: [ + metaLine( + { + id: "019e9836-5001-7821-a9c2-3ffd26a1199b", + model_provider: "openai", + }, + "2026-06-05T10:00:00.000Z", + ), + userMessageLine("first openai task"), + ].join("\n"), + }, + { + id: "0190abcd-1234-7821-a9c2-3ffd26a11000", + content: [ + metaLine( + { + id: "0190abcd-1234-7821-a9c2-3ffd26a11000", + model_provider: "codex-multi-auth-runtime-proxy", + }, + "2026-06-05T12:00:00.000Z", + ), + userMessageLine("a rotated session"), + ].join("\n"), + }, + ]); + + const code = runHistoryCommand(["list"], deps); + + expect(code).toBe(0); + const output = allOutput(deps.logInfo); + expect(output).toContain("openai"); + expect(output).toContain("codex-multi-auth-runtime-proxy"); + expect(output).toContain("019e9836-5001-7821-a9c2-3ffd26a1199b"); + expect(output).toContain("0190abcd-1234-7821-a9c2-3ffd26a11000"); + }); + + it("defaults to list when no subcommand is given", () => { + const deps = createDeps([ + { + id: "019e9836-5001-7821-a9c2-3ffd26a1199b", + content: metaLine(), + }, + ]); + + const code = runHistoryCommand([], deps); + + expect(code).toBe(0); + expect(allOutput(deps.logInfo)).toContain( + "019e9836-5001-7821-a9c2-3ffd26a1199b", + ); + }); + + it("sorts most-recent first", () => { + const deps = createDeps([ + { + id: "00000000-0000-7821-a9c2-00000000aaaa", + content: metaLine( + { id: "00000000-0000-7821-a9c2-00000000aaaa" }, + "2026-06-01T00:00:00.000Z", + ), + }, + { + id: "11111111-1111-7821-a9c2-11111111bbbb", + content: metaLine( + { id: "11111111-1111-7821-a9c2-11111111bbbb" }, + "2026-06-10T00:00:00.000Z", + ), + }, + ]); + + const code = runHistoryCommand(["list", "--json"], deps); + + expect(code).toBe(0); + const payload = JSON.parse(allOutput(deps.logInfo)); + expect(payload.count).toBe(2); + expect(payload.sessions[0].id).toBe("11111111-1111-7821-a9c2-11111111bbbb"); + expect(payload.sessions[1].id).toBe("00000000-0000-7821-a9c2-00000000aaaa"); + }); + + it("emits machine-readable JSON with provider field", () => { + const deps = createDeps([ + { + id: "019e9836-5001-7821-a9c2-3ffd26a1199b", + content: metaLine({ model_provider: "codex-multi-auth-runtime-proxy" }), + }, + ]); + + const code = runHistoryCommand(["list", "--json"], deps); + + expect(code).toBe(0); + const payload = JSON.parse(allOutput(deps.logInfo)); + expect(payload.count).toBe(1); + expect(payload.sessions[0].provider).toBe( + "codex-multi-auth-runtime-proxy", + ); + // list summaries should not carry the heavier detail fields + expect(payload.sessions[0].messages).toBeUndefined(); + expect(payload.sessions[0].cliVersion).toBeUndefined(); + }); + + it("reports an empty listing without error when the sessions dir is missing", () => { + const deps = createDeps([], { readDirRecursive: () => [] }); + + const code = runHistoryCommand(["list"], deps); + + expect(code).toBe(0); + expect(allOutput(deps.logInfo)).toContain("No local Codex sessions found"); + }); + + it("treats a leading flag as a list arg (history --json with no explicit list)", () => { + const deps = createDeps([ + { + id: "019e9836-5001-7821-a9c2-3ffd26a1199b", + content: metaLine(), + }, + ]); + + const code = runHistoryCommand(["--json"], deps); + + expect(code).toBe(0); + expect(deps.logError).not.toHaveBeenCalled(); + const payload = JSON.parse(allOutput(deps.logInfo)); + expect(payload.count).toBe(1); + expect(payload.sessions[0].id).toBe( + "019e9836-5001-7821-a9c2-3ffd26a1199b", + ); + }); + + it("reports an empty listing when readDirRecursive throws (missing dir)", () => { + // The default readDirRecursive swallows ENOENT internally, but an injected + // one that throws must not crash the command — lock the failure path. + const deps = createDeps([], { + readDirRecursive: () => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }, + }); + + const code = runHistoryCommand(["list"], deps); + + expect(code).toBe(0); + expect(allOutput(deps.logInfo)).toContain("No local Codex sessions found"); + }); + + it("resolves the sessions dir from an overridden (Windows) codex home", () => { + const sessionFile = + "D:\\custom\\.codex\\sessions\\2026\\06\\05\\rollout-2026-06-05T22-35-56-019e9836-5001-7821-a9c2-3ffd26a1199b.jsonl"; + const seenDirs: string[] = []; + const deps = createDeps([], { + getCodexHome: () => "D:\\custom\\.codex", + readDirRecursive: (dir: string) => { + seenDirs.push(dir); + return [sessionFile]; + }, + readFile: () => metaLine(), + }); + + const code = runHistoryCommand(["list", "--json"], deps); + + expect(code).toBe(0); + // The command must look under /sessions, not ~/.codex. + expect(seenDirs).toContain("D:\\custom\\.codex\\sessions"); + const payload = JSON.parse(allOutput(deps.logInfo)); + expect(payload.count).toBe(1); + }); + + it("names the overridden home in the empty-listing message", () => { + const deps = createDeps([], { + getCodexHome: () => "D:\\custom\\.codex", + readDirRecursive: () => [], + }); + + const code = runHistoryCommand(["list"], deps); + + expect(code).toBe(0); + expect(allOutput(deps.logInfo)).toContain("D:\\custom\\.codex"); + }); + + it("tolerates malformed JSONL lines without dropping the session", () => { + const deps = createDeps([ + { + id: "019e9836-5001-7821-a9c2-3ffd26a1199b", + content: [ + "{ this is not valid json", + metaLine(), + "", + "another broken line }", + userMessageLine("still parsed"), + ].join("\n"), + }, + ]); + + const code = runHistoryCommand(["list", "--json"], deps); + + expect(code).toBe(0); + const payload = JSON.parse(allOutput(deps.logInfo)); + expect(payload.count).toBe(1); + expect(payload.sessions[0].id).toBe( + "019e9836-5001-7821-a9c2-3ffd26a1199b", + ); + }); + + it("skips rollout files that never declare session_meta", () => { + const deps = createDeps([ + { + id: "019e9836-5001-7821-a9c2-3ffd26a1199b", + content: userMessageLine("orphan message with no meta"), + }, + ]); + + const code = runHistoryCommand(["list", "--json"], deps); + + expect(code).toBe(0); + const payload = JSON.parse(allOutput(deps.logInfo)); + expect(payload.count).toBe(0); + }); +}); + +describe("runHistoryCommand show", () => { + it("shows provider metadata and first user messages", () => { + const deps = createDeps([ + { + id: "019e9836-5001-7821-a9c2-3ffd26a1199b", + content: [ + metaLine({ model_provider: "openai" }), + userMessageLine("message one"), + userMessageLine("message two"), + userMessageLine("message three"), + userMessageLine("message four should be trimmed"), + ].join("\n"), + }, + ]); + + const code = runHistoryCommand( + ["show", "019e9836-5001-7821-a9c2-3ffd26a1199b"], + deps, + ); + + expect(code).toBe(0); + const output = allOutput(deps.logInfo); + expect(output).toContain("provider: openai"); + expect(output).toContain("message one"); + expect(output).toContain("message three"); + expect(output).not.toContain("message four should be trimmed"); + }); + + it("returns an error when the session id is unknown", () => { + const deps = createDeps([ + { id: "019e9836-5001-7821-a9c2-3ffd26a1199b", content: metaLine() }, + ]); + + const code = runHistoryCommand(["show", "does-not-exist"], deps); + + expect(code).toBe(1); + expect(deps.logError).toHaveBeenCalledWith( + "Session not found: does-not-exist", + ); + }); + + it("returns an error when no session id is supplied", () => { + const deps = createDeps([]); + + const code = runHistoryCommand(["show"], deps); + + expect(code).toBe(1); + expect(deps.logError).toHaveBeenCalledWith( + "Missing session id. Usage: codex-multi-auth history show [--json]", + ); + }); +}); + +describe("runHistoryCommand routing", () => { + it("rejects an unknown subcommand", () => { + const deps = createDeps([]); + + const code = runHistoryCommand(["bogus"], deps); + + expect(code).toBe(1); + expect(deps.logError).toHaveBeenCalledWith( + "Unknown history command: bogus", + ); + }); + + it("prints usage for --help", () => { + const deps = createDeps([]); + + const code = runHistoryCommand(["--help"], deps); + + expect(code).toBe(0); + expect(allOutput(deps.logInfo)).toContain( + "Usage: codex-multi-auth history", + ); + }); +}); diff --git a/test/documentation.test.ts b/test/documentation.test.ts index d13ef7d6a..90c76aa4a 100644 --- a/test/documentation.test.ts +++ b/test/documentation.test.ts @@ -357,7 +357,7 @@ describe("Documentation Integrity", () => { `codex-multi-auth fix --live --model ${DEFAULT_MODEL}`, ); expect(commandRef).toContain( - "| `--json` | verify-flagged, verify, why-selected, best, forecast, report, usage, budget, models, monitor, integrations, fix, doctor, config explain, debug bundle |", + "| `--json` | verify-flagged, verify, why-selected, best, forecast, report, usage, budget, models, monitor, integrations, fix, doctor, config explain, debug bundle, history |", ); expect(commandRef).toContain( "| `--explain` | forecast, report | Include reasoning details (forecast text/JSON, report text) |",