From 5717706e62159b1abc05a13af50c2f6bf2d6945a Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:00:27 -0600 Subject: [PATCH 01/22] docs: update AGENTS.md for gpt-5.1-codex-max support - Update overview to reflect new gpt-5.1-codex-max model as default - Add note about xhigh reasoning effort exclusivity to gpt-5.1-codex-max - Document expanded model lineup matching Codex CLI --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d741405..61082d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides coding guidance for AI agents (including Claude Code, Codex, ## Overview -This is an **opencode plugin** that enables OAuth authentication with OpenAI's ChatGPT Plus/Pro Codex backend. It allows users to access `gpt-5-codex`, `gpt-5-codex-mini`, and `gpt-5` models through their ChatGPT subscription instead of using OpenAI Platform API credits. +This is an **opencode plugin** that enables OAuth authentication with OpenAI's ChatGPT Plus/Pro Codex backend. It now mirrors the Codex CLI lineup, making `gpt-5.1-codex-max` (with optional `xhigh` reasoning) the default alongside the existing `gpt-5.1-codex`, `gpt-5.1-codex-mini`, and legacy `gpt-5` models—all available through a ChatGPT subscription instead of OpenAI Platform API credits. **Key architecture principle**: 7-step fetch flow that intercepts opencode's OpenAI SDK requests, transforms them for the ChatGPT backend API, and handles OAuth token management. @@ -157,6 +157,8 @@ This plugin **intentionally differs from opencode defaults** because it accesses | `store` | true | false | Required for ChatGPT backend | | `include` | (not set) | `["reasoning.encrypted_content"]` | Required for stateless operation | +> **Extra High reasoning**: `reasoningEffort: "xhigh"` is only honored for `gpt-5.1-codex-max`. Other models automatically downgrade it to `high` so their API calls remain valid. + ## File Paths & Locations - **Plugin config**: `~/.opencode/openhax-codex-config.json` From 41755ac4546c64ff2e4c1d7c09681e60441364b8 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:00:35 -0600 Subject: [PATCH 02/22] chore: add v3.3.0 changelog entry for gpt-5.1-codex-max - Document new Codex Max support with xhigh reasoning - Note configuration changes and sample updates - Record automatic reasoning effort downgrade fix for compatibility --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18d3c1..0fcfa80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project are documented here. Dates use the ISO format (YYYY-MM-DD). +## [3.3.0] - 2025-11-19 +### Added +- Codex Max support that mirrors the Codex CLI: normalization for every `gpt-5.1-codex-max` alias, `reasoningEffort: "xhigh"`, and unit tests covering both the transformer and request body integration path. +- Documentation and configuration updates calling out Codex Max as the flagship preset, plus refreshed samples showing how to opt into the Extra High reasoning mode. + +### Changed +- Sample configs (`full` + `minimal`), README tables, AGENTS.md, and the diagnostics script now prefer `gpt-5.1-codex-max`, keeping plugin defaults aligned with Codex CLI behaviour. + +### Fixed +- Requests that specify `reasoningEffort: "xhigh"` for non-supported models are now automatically downgraded to `high`, preventing API errors when Codex Max isn't selected. + ## [3.2.0] - 2025-11-13 ### Added - GPT-5.1 family integration: normalization for `gpt-5.1`/`gpt-5.1-codex`/`gpt-5.1-codex-mini`, expanded reasoning heuristics (including `reasoningEffort: "none"`), and preservation of the native `shell`/`apply_patch` tools emitted by Codex CLI. From 9c7fb710ea1b4c41511feaa871bd9dc192826663 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:00:42 -0600 Subject: [PATCH 03/22] docs: update README for gpt-5.1-codex-max integration - Add gpt-5.1-codex-max configuration with xhigh reasoning support - Update model count from 20 to 21 variants - Expand model comparison table with Codex Max as flagship default - Add note about xhigh reasoning exclusivity and auto-downgrade behavior --- README.md | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 557efa0..27a0f3b 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,22 @@ For the complete experience with all reasoning variants matching the official Co "store": false }, "models": { + "gpt-5.1-codex-max": { + "name": "GPT 5.1 Codex Max (OAuth)", + "limit": { + "context": 400000, + "output": 128000 + }, + "options": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium", + "include": [ + "reasoning.encrypted_content" + ], + "store": false + } + }, "gpt-5.1-codex-low": { "name": "GPT 5.1 Codex Low (OAuth)", "limit": { @@ -419,7 +435,7 @@ For the complete experience with all reasoning variants matching the official Co **Global config**: `~/.config/opencode/opencode.json` **Project config**: `/.opencode.json` - This now gives you 20 model variants: the new GPT-5.1 lineup (recommended) plus every legacy gpt-5 preset for backwards compatibility. + This now gives you 21 model variants: the refreshed GPT-5.1 lineup (with Codex Max as the default) plus every legacy gpt-5 preset for backwards compatibility. All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5 High (OAuth)", etc. @@ -431,6 +447,7 @@ When using [`config/full-opencode.json`](./config/full-opencode.json), you get t | CLI Model ID | TUI Display Name | Reasoning Effort | Best For | |--------------|------------------|-----------------|----------| +| `gpt-5.1-codex-max` | GPT 5.1 Codex Max (OAuth) | Medium (Extra High optional) | Default flagship tier with optional `xhigh` reasoning for long, complex runs | | `gpt-5.1-codex-low` | GPT 5.1 Codex Low (OAuth) | Low | Fast code generation on the newest Codex tier | | `gpt-5.1-codex-medium` | GPT 5.1 Codex Medium (OAuth) | Medium | Balanced code + tooling workflows | | `gpt-5.1-codex-high` | GPT 5.1 Codex High (OAuth) | High | Multi-step coding tasks with deep tool use | @@ -441,6 +458,8 @@ When using [`config/full-opencode.json`](./config/full-opencode.json), you get t | `gpt-5.1-medium` | GPT 5.1 Medium (OAuth) | Medium | Default adaptive reasoning for everyday work | | `gpt-5.1-high` | GPT 5.1 High (OAuth) | High | Deep analysis when reliability matters most | +> **Extra High reasoning:** `reasoningEffort: "xhigh"` is exclusive to `gpt-5.1-codex-max`. Other models automatically map that option to `high` so their API calls remain valid. + #### Legacy GPT-5 lineup (still supported) | CLI Model ID | TUI Display Name | Reasoning Effort | Best For | @@ -502,7 +521,7 @@ These defaults match the official Codex CLI behavior and can be customized (see ### Recommended: Use Pre-Configured File The easiest way to get started is to use [`config/full-opencode.json`](./config/full-opencode.json), which provides: -- 20 pre-configured model variants matching the latest Codex CLI presets (GPT-5.1 + GPT-5) +- 21 pre-configured model variants matching the latest Codex CLI presets (GPT-5.1 Codex Max + GPT-5.1 + GPT-5) - Optimal settings for each reasoning level - All variants visible in the opencode model selector @@ -518,12 +537,12 @@ If you want to customize settings yourself, you can configure options at provide | Setting | GPT-5 / GPT-5.1 Values | GPT-5-Codex / Codex Mini Values | Plugin Default | |---------|-------------|-------------------|----------------| -| `reasoningEffort` | `none`, `minimal`, `low`, `medium`, `high` | `low`, `medium`, `high` | `medium` | +| `reasoningEffort` | `none`, `minimal`, `low`, `medium`, `high` | `low`, `medium`, `high`, `xhigh`* | `medium` | | `reasoningSummary` | `auto`, `detailed` | `auto`, `detailed` | `auto` | | `textVerbosity` | `low`, `medium`, `high` | `medium` only | `medium` | | `include` | Array of strings | Array of strings | `["reasoning.encrypted_content"]` | -> **Note**: `minimal` effort is auto-normalized to `low` for gpt-5-codex (not supported by the API). `none` is only supported on GPT-5.1 general models; when used with legacy gpt-5 it is normalized to `minimal`. +> **Note**: `minimal` effort is auto-normalized to `low` for gpt-5-codex (not supported by the API). `none` is only supported on GPT-5.1 general models; when used with legacy gpt-5 it is normalized to `minimal`. `xhigh` is exclusive to `gpt-5.1-codex-max`—other Codex presets automatically map it to `high`. #### Plugin-Level Settings From 8536bf12837fdcafffa1720710c7daac8837cf44 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:00:52 -0600 Subject: [PATCH 04/22] config: add gpt-5.1-codex-max to full-opencode.json - Add flagship Codex Max model with 400k context and 128k output limits - Configure with medium reasoning effort as default - Include encrypted_content for stateless operation - Set store: false for ChatGPT backend compatibility --- config/full-opencode.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/config/full-opencode.json b/config/full-opencode.json index dd4ea69..64022e7 100644 --- a/config/full-opencode.json +++ b/config/full-opencode.json @@ -15,6 +15,22 @@ "store": false }, "models": { + "gpt-5.1-codex-max": { + "name": "GPT 5.1 Codex Max (OAuth)", + "limit": { + "context": 400000, + "output": 128000 + }, + "options": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium", + "include": [ + "reasoning.encrypted_content" + ], + "store": false + } + }, "gpt-5.1-codex-low": { "name": "GPT 5.1 Codex Low (OAuth)", "limit": { From 564bac727c59c587fd7a9a4077a0368bd44c2c7d Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:00:59 -0600 Subject: [PATCH 05/22] config: update minimal-opencode.json default to gpt-5.1-codex-max - Change default model from gpt-5.1-codex to gpt-5.1-codex-max - Align minimal config with new flagship Codex Max model - Provide users with best-in-class default experience --- config/minimal-opencode.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/minimal-opencode.json b/config/minimal-opencode.json index 6c41e04..0b2d291 100644 --- a/config/minimal-opencode.json +++ b/config/minimal-opencode.json @@ -8,5 +8,5 @@ } } }, - "model": "openai/gpt-5.1-codex" + "model": "openai/gpt-5.1-codex-max" } From 2f2d238f8c26c5155d8d72d746edaf26b200f132 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:01:12 -0600 Subject: [PATCH 06/22] docs: update CONFIG_FIELDS.md for gpt-5.1-codex-max - Add gpt-5.1-codex-max example configuration - Document xhigh reasoning effort exclusivity and auto-clamping - Remove outdated duplicate key example section - Clean up reasoning effort notes with new xhigh behavior --- docs/development/CONFIG_FIELDS.md | 34 +++++++------------------------ 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index 6f4584e..25f166d 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -285,6 +285,11 @@ const parsedModel: ModelsDev.Model = { ```json { + "gpt-5.1-codex-max": { + "id": "gpt-5.1-codex-max", + "name": "GPT 5.1 Codex Max (OAuth)", + "options": { "reasoningEffort": "medium" } + }, "gpt-5.1-codex-low": { "id": "gpt-5.1-codex", "name": "GPT 5.1 Codex Low (OAuth)", @@ -301,36 +306,11 @@ const parsedModel: ModelsDev.Model = { **Why this matters:** - Config keys mirror the Codex CLI's 5.1 presets, making it obvious which tier you're targeting. - `reasoningEffort: "none"` is only valid for GPT-5.1 general models—the plugin automatically downgrades unsupported values for Codex/Codex Mini. -- Legacy GPT-5 entries can stick around for backwards compatibility, but new installs should prefer the 5.1 naming. - ---- - -### Example 4: If We Made Config Key = ID ❌ - -```json -{ - "gpt-5-codex": { - "id": "gpt-5-codex", - "name": "GPT 5 Codex Low (OAuth)", - "options": { "reasoningEffort": "low" } - }, - "gpt-5-codex": { // ❌ DUPLICATE KEY ERROR! - "id": "gpt-5-codex", - "name": "GPT 5 Codex High (OAuth)", - "options": { "reasoningEffort": "high" } - } -} -``` - -**Problem:** JavaScript objects can't have duplicate keys! - -**Result:** ❌ Can't have multiple variants - -### Reasoning Effort quick notes -- `reasoningEffort: "none"` is exclusive to GPT-5.1 general models and maps to the new "no reasoning" mode introduced by OpenAI. +- `reasoningEffort: "xhigh"` is exclusive to `gpt-5.1-codex-max`; other models automatically clamp it to `high`. - Legacy GPT-5, GPT-5-Codex, and Codex Mini presets automatically clamp unsupported values (`none` → `minimal`/`low`, `minimal` → `low` for Codex). - Mixing GPT-5.1 and GPT-5 presets inside the same config is fine—just keep config keys unique and let the plugin normalize them. + --- ## Why We Need Different Config Keys From 266606bbbf4eb19940f02fff56d4910c8765face Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:01:20 -0600 Subject: [PATCH 07/22] docs: add persistent logging note to TESTING.md - Document new per-request JSON logging and rolling log files - Note environment variables for enabling live console output - Help developers debug with comprehensive logging capabilities --- docs/development/TESTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index c94ce43..18b4a5c 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -2,6 +2,8 @@ Comprehensive testing matrix for all config scenarios and backwards compatibility. +> **Logging note:** All test runs and plugin executions now write per-request JSON files plus a rolling `codex-plugin.log` under `~/.opencode/logs/codex-plugin/`. Set `ENABLE_PLUGIN_REQUEST_LOGGING=1` or `DEBUG_CODEX_PLUGIN=1` if you also want live console output in addition to the files. + ## Test Scenarios Matrix ### Scenario 1: Default OpenCode Models (No Custom Config) From 9175628009baab52de2259c395083a34b9c18243 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:01:27 -0600 Subject: [PATCH 08/22] feat: implement persistent rolling logging in logger.ts - Add rolling log file under ~/.opencode/logs/codex-plugin/ - Write structured JSON entries with timestamps for all log levels - Maintain per-request stage files for detailed debugging - Improve error handling and log forwarding to OpenCode app - Separate console logging controls from file logging --- lib/logger.ts | 54 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/lib/logger.ts b/lib/logger.ts index b59a3e3..4df484f 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,21 +1,32 @@ import type { OpencodeClient } from "@opencode-ai/sdk"; -import { writeFileSync, existsSync } from "node:fs"; +import { writeFileSync, appendFileSync } from "node:fs"; import { join } from "node:path"; -import { homedir } from "node:os"; import { PLUGIN_NAME } from "./constants.js"; import { getOpenCodePath, ensureDirectory } from "./utils/file-system-utils.js"; export const LOGGING_ENABLED = process.env.ENABLE_PLUGIN_REQUEST_LOGGING === "1"; -const DEBUG_ENABLED = process.env.DEBUG_CODEX_PLUGIN === "1" || LOGGING_ENABLED; +const DEBUG_FLAG_ENABLED = process.env.DEBUG_CODEX_PLUGIN === "1"; +const DEBUG_ENABLED = DEBUG_FLAG_ENABLED || LOGGING_ENABLED; +const CONSOLE_LOGGING_ENABLED = LOGGING_ENABLED || DEBUG_FLAG_ENABLED; const LOG_DIR = getOpenCodePath("logs", "codex-plugin"); +const ROLLING_LOG_FILE = join(LOG_DIR, "codex-plugin.log"); type LogLevel = "debug" | "info" | "warn" | "error"; + type LoggerOptions = { client?: OpencodeClient; directory?: string; }; +type RollingLogEntry = { + timestamp: string; + service: string; + level: LogLevel; + message: string; + extra?: Record; +}; + let requestCounter = 0; let loggerClient: OpencodeClient | undefined; let projectDirectory: string | undefined; @@ -45,7 +56,6 @@ export function configureLogger(options: LoggerOptions = {}): void { } export function logRequest(stage: string, data: Record): void { - if (!LOGGING_ENABLED) return; const payload = { timestamp: new Date().toISOString(), requestId: ++requestCounter, @@ -64,7 +74,6 @@ export function logRequest(stage: string, data: Record): void { } export function logDebug(message: string, data?: unknown): void { - if (!DEBUG_ENABLED) return; emit("debug", message, normalizeExtra(data)); } @@ -81,25 +90,35 @@ export function logError(message: string, data?: unknown): void { } function emit(level: LogLevel, message: string, extra?: Record): void { - const payload = { + const sanitizedExtra = sanitizeExtra(extra); + const entry: RollingLogEntry = { + timestamp: new Date().toISOString(), service: PLUGIN_NAME, level, message, - extra: sanitizeExtra(extra), + extra: sanitizedExtra, }; + appendRollingLog(entry); + if (loggerClient?.app) { void loggerClient.app .log({ - body: payload, + body: entry, query: projectDirectory ? { directory: projectDirectory } : undefined, }) - .catch((error) => fallback(level, message, payload.extra, error)); - return; + .catch((error) => + logToConsole("warn", "Failed to forward log entry", { + error: error instanceof Error ? error.message : String(error), + }), + ); } - fallback(level, message, payload.extra); + + logToConsole(level, message, sanitizedExtra); } -function fallback(level: LogLevel, message: string, extra?: Record, error?: unknown): void { +function logToConsole(level: LogLevel, message: string, extra?: Record, error?: unknown): void { + const shouldLog = CONSOLE_LOGGING_ENABLED || level === "warn" || level === "error"; + if (!shouldLog) return; const prefix = `[${PLUGIN_NAME}] ${message}`; const details = extra ? `${prefix} ${JSON.stringify(extra)}` : prefix; if (level === "error") { @@ -146,3 +165,14 @@ function persistRequestStage(stage: string, payload: Record): s return undefined; } } + +function appendRollingLog(entry: RollingLogEntry): void { + try { + ensureLogDir(); + appendFileSync(ROLLING_LOG_FILE, `${JSON.stringify(entry)}\n`, "utf8"); + } catch (err) { + logToConsole("warn", "Failed to write rolling log", { + error: err instanceof Error ? err.message : String(err), + }); + } +} From 9cac2c2dea8f1aa3d1d09c97c819a243f22b1dd0 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:01:47 -0600 Subject: [PATCH 09/22] feat: add gpt-5.1-codex-max support to request transformer - Add model normalization for all codex-max variants - Implement xhigh reasoning effort with auto-downgrade for non-max models - Add Codex Max specific reasoning effort validation and normalization - Ensure compatibility with existing model configurations --- lib/request/request-transformer.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 8541262..b8b23ca 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -313,6 +313,7 @@ export function normalizeModel(model: string | undefined): string { const contains = (needle: string) => sanitized.includes(needle); const hasGpt51 = contains("gpt-5-1") || sanitized.includes("gpt51"); + const hasCodexMax = contains("codex-max") || contains("codexmax"); if (contains("gpt-5-1-codex-mini") || (hasGpt51 && contains("codex-mini"))) { return "gpt-5.1-codex-mini"; @@ -320,6 +321,9 @@ export function normalizeModel(model: string | undefined): string { if (contains("codex-mini")) { return "gpt-5.1-codex-mini"; } + if (hasCodexMax) { + return "gpt-5.1-codex-max"; + } if (contains("gpt-5-1-codex") || (hasGpt51 && contains("codex"))) { return "gpt-5.1-codex"; } @@ -384,6 +388,7 @@ export function getReasoningConfig( normalizedOriginal.includes("codex-mini") || normalizedOriginal.includes("codex mini") || normalizedOriginal.includes("codex_mini"); + const isCodexMax = normalized === "gpt-5.1-codex-max"; const isCodexFamily = normalized.startsWith("gpt-5-codex") || normalized.startsWith("gpt-5.1-codex") || @@ -405,6 +410,11 @@ export function getReasoningConfig( } let effort = userConfig.reasoningEffort || defaultEffort; + const requestedXHigh = effort === "xhigh"; + + if (requestedXHigh && !isCodexMax) { + effort = "high"; + } if (isCodexMini) { if (effort === "minimal" || effort === "low" || effort === "none") { @@ -413,6 +423,10 @@ export function getReasoningConfig( if (effort !== "high") { effort = "medium"; } + } else if (isCodexMax) { + if (effort === "minimal" || effort === "none") { + effort = "low"; + } } else if (isCodexFamily) { if (effort === "minimal" || effort === "none") { effort = "low"; From 88008a93e16e35e7ee466eefe990c5bf66b4d2b9 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:01:55 -0600 Subject: [PATCH 10/22] types: add xhigh reasoning effort to TypeScript interfaces - Add xhigh to ConfigOptions.reasoningEffort union type - Add xhigh to ReasoningConfig.effort union type - Enable type-safe usage of extra high reasoning for gpt-5.1-codex-max --- lib/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/types.ts b/lib/types.ts index 3397674..f1d4e43 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -34,7 +34,7 @@ export interface UserConfig { * Configuration options for reasoning and text settings */ export interface ConfigOptions { - reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high"; + reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; reasoningSummary?: "auto" | "concise" | "detailed"; textVerbosity?: "low" | "medium" | "high"; include?: string[]; @@ -44,7 +44,7 @@ export interface ConfigOptions { * Reasoning configuration for requests */ export interface ReasoningConfig { - effort: "none" | "minimal" | "low" | "medium" | "high"; + effort: "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; summary: "auto" | "concise" | "detailed"; } From b309387f0940f979b4067a8f82ef31ea28976b8c Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:02:03 -0600 Subject: [PATCH 11/22] test: add gpt-5.1-codex-max to test-all-models.sh - Add test case for new flagship Codex Max model - Verify medium reasoning effort with auto summary and medium verbosity - Ensure comprehensive testing coverage for all model variants --- scripts/test-all-models.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-all-models.sh b/scripts/test-all-models.sh index 3cc3c52..79e19f2 100755 --- a/scripts/test-all-models.sh +++ b/scripts/test-all-models.sh @@ -164,6 +164,7 @@ EOCONFIG # ============================================================================ update_config "full" + test_model "gpt-5.1-codex-max" "gpt-5.1-codex-max" "medium" "auto" "medium" test_model "gpt-5.1-codex-low" "gpt-5.1-codex" "low" "auto" "medium" test_model "gpt-5.1-codex-medium" "gpt-5.1-codex" "medium" "auto" "medium" test_model "gpt-5.1-codex-high" "gpt-5.1-codex" "high" "detailed" "medium" From c452368fbd2f6cea559de03a3d8c2f9a7618e0c7 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:02:11 -0600 Subject: [PATCH 12/22] test: fix codex-fetcher test headers mock - Add default Authorization header to createCodexHeaders mock - Prevent test failures due to missing required headers - Ensure consistent test environment across all test runs --- test/codex-fetcher.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/codex-fetcher.test.ts b/test/codex-fetcher.test.ts index 1eb4790..57edbf5 100644 --- a/test/codex-fetcher.test.ts +++ b/test/codex-fetcher.test.ts @@ -62,6 +62,7 @@ describe('createCodexFetcher', () => { refreshAndUpdateTokenMock.mockReset(); transformRequestForCodexMock.mockReset(); createCodexHeadersMock.mockReset(); + createCodexHeadersMock.mockImplementation(() => new Headers({ Authorization: 'Bearer token' })); handleErrorResponseMock.mockReset(); handleSuccessResponseMock.mockReset(); handleSuccessResponseMock.mockResolvedValue(new Response('handled', { status: 200 })); From 3ee37ef058d0c4a2d86ddd8e03e38e0344c6ed68 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:02:32 -0600 Subject: [PATCH 13/22] test: update logger tests for persistent rolling logging - Add tests for rolling log file functionality - Update test structure to handle module caching properly - Test console logging behavior with environment variables - Verify error handling for file write failures - Ensure appendFileSync is called for all log entries --- test/logger.test.ts | 151 ++++++++++++++++++++------------------------ 1 file changed, 68 insertions(+), 83 deletions(-) diff --git a/test/logger.test.ts b/test/logger.test.ts index e363ba9..a251c54 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -2,130 +2,115 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const fsMocks = { writeFileSync: vi.fn(), + appendFileSync: vi.fn(), mkdirSync: vi.fn(), existsSync: vi.fn(), }; -const homedirMock = vi.fn(() => '/mock-home'); - vi.mock('node:fs', () => ({ writeFileSync: fsMocks.writeFileSync, + appendFileSync: fsMocks.appendFileSync, mkdirSync: fsMocks.mkdirSync, existsSync: fsMocks.existsSync, })); vi.mock('node:os', () => ({ __esModule: true, - homedir: homedirMock, + homedir: () => '/mock-home', })); -describe('Logger Module', () => { - const originalEnv = { ...process.env }; - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - beforeEach(() => { - vi.clearAllMocks(); - Object.assign(process.env, originalEnv); - delete process.env.ENABLE_PLUGIN_REQUEST_LOGGING; - delete process.env.DEBUG_CODEX_PLUGIN; - fsMocks.writeFileSync.mockReset(); - fsMocks.mkdirSync.mockReset(); - fsMocks.existsSync.mockReset(); - homedirMock.mockReturnValue('/mock-home'); - logSpy.mockClear(); - warnSpy.mockClear(); - errorSpy.mockClear(); - }); +const originalEnv = { ...process.env }; +const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); +const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + +beforeEach(() => { + vi.resetModules(); + Object.assign(process.env, originalEnv); + delete process.env.ENABLE_PLUGIN_REQUEST_LOGGING; + delete process.env.DEBUG_CODEX_PLUGIN; + fsMocks.writeFileSync.mockReset(); + fsMocks.appendFileSync.mockReset(); + fsMocks.mkdirSync.mockReset(); + fsMocks.existsSync.mockReset(); + logSpy.mockClear(); + warnSpy.mockClear(); +}); - afterEach(() => { - Object.assign(process.env, originalEnv); - }); +afterEach(() => { + Object.assign(process.env, originalEnv); +}); +describe('logger', () => { it('LOGGING_ENABLED reflects env state', async () => { process.env.ENABLE_PLUGIN_REQUEST_LOGGING = '1'; const { LOGGING_ENABLED } = await import('../lib/logger.js'); expect(LOGGING_ENABLED).toBe(true); }); -it('logRequest skips writing when logging disabled', async () => { - // Since LOGGING_ENABLED is evaluated at module load time, - // and ES modules are cached, we need to test the behavior - // based on the current environment state - delete process.env.ENABLE_PLUGIN_REQUEST_LOGGING; - - // Clear module cache to get fresh evaluation - vi.unmock('../lib/logger.js'); + it('logRequest writes stage file and rolling log by default', async () => { + fsMocks.existsSync.mockReturnValue(false); const { logRequest } = await import('../lib/logger.js'); - - fsMocks.existsSync.mockReturnValue(true); + logRequest('stage-one', { foo: 'bar' }); - - // If LOGGING_ENABLED was false, no writes should occur - // Note: Due to module caching in vitest, this test assumes - // the environment was clean when the module was first loaded - }); - it('logRequest creates directory and writes when enabled', async () => { - process.env.ENABLE_PLUGIN_REQUEST_LOGGING = '1'; - let existsCall = 0; - fsMocks.existsSync.mockImplementation(() => existsCall++ > 0); - const { logRequest } = await import('../lib/logger.js'); + expect(fsMocks.mkdirSync).toHaveBeenCalledWith('/mock-home/.opencode/logs/codex-plugin', { recursive: true }); + const [requestPath, payload, encoding] = fsMocks.writeFileSync.mock.calls[0]; + expect(requestPath).toBe('/mock-home/.opencode/logs/codex-plugin/request-1-stage-one.json'); + expect(encoding).toBe('utf8'); + const parsedPayload = JSON.parse(payload as string); + expect(parsedPayload.stage).toBe('stage-one'); + expect(parsedPayload.foo).toBe('bar'); + + const [logPath, logLine, logEncoding] = fsMocks.appendFileSync.mock.calls[0]; + expect(logPath).toBe('/mock-home/.opencode/logs/codex-plugin/codex-plugin.log'); + expect(logEncoding).toBe('utf8'); + expect(logLine as string).toContain('"stage":"stage-one"'); + expect(logSpy).not.toHaveBeenCalled(); + }); - logRequest('before', { some: 'data' }); + it('logDebug appends to rolling log without printing to console by default', async () => { + fsMocks.existsSync.mockReturnValue(true); + const { logDebug } = await import('../lib/logger.js'); - expect(fsMocks.mkdirSync).toHaveBeenCalledWith('/mock-home/.opencode/logs/codex-plugin', { recursive: true }); - expect(fsMocks.writeFileSync).toHaveBeenCalledOnce(); + logDebug('debug-message', { detail: 'info' }); - const [, jsonString] = fsMocks.writeFileSync.mock.calls[0]; - const parsed = JSON.parse(jsonString as string); - expect(parsed.stage).toBe('before'); - expect(parsed.some).toBe('data'); - expect(typeof parsed.requestId).toBe('number'); + expect(fsMocks.appendFileSync).toHaveBeenCalledTimes(1); + expect(logSpy).not.toHaveBeenCalled(); }); - it('logRequest records errors from writeFileSync', async () => { - process.env.ENABLE_PLUGIN_REQUEST_LOGGING = '1'; + it('logWarn emits to console even without env overrides', async () => { fsMocks.existsSync.mockReturnValue(true); - fsMocks.writeFileSync.mockImplementation(() => { - throw new Error('boom'); - }); - const { logRequest } = await import('../lib/logger.js'); + const { logWarn } = await import('../lib/logger.js'); - logRequest('error-stage', { boom: true }); + logWarn('warning'); - expect(warnSpy).toHaveBeenCalledWith('[openai-codex-plugin] Failed to persist request log {"stage":"error-stage","error":"boom"}'); + expect(warnSpy).toHaveBeenCalledWith('[openai-codex-plugin] warning'); }); - it('logDebug logs only when enabled', async () => { - // Ensure a clean import without debug/logging enabled - delete process.env.DEBUG_CODEX_PLUGIN; - delete process.env.ENABLE_PLUGIN_REQUEST_LOGGING; - await vi.resetModules(); - let mod = await import('../lib/logger.js'); - mod.logDebug('should not log'); + it('logInfo only mirrors to console when logging env is enabled', async () => { + fsMocks.existsSync.mockReturnValue(true); + const { logInfo } = await import('../lib/logger.js'); + logInfo('info-message'); expect(logSpy).not.toHaveBeenCalled(); - // Enable debug and reload module to re-evaluate DEBUG_ENABLED - process.env.DEBUG_CODEX_PLUGIN = '1'; + process.env.ENABLE_PLUGIN_REQUEST_LOGGING = '1'; await vi.resetModules(); - mod = await import('../lib/logger.js'); - mod.logDebug('hello', { a: 1 }); - expect(logSpy).toHaveBeenCalledWith('[openai-codex-plugin] hello {"a":1}'); + fsMocks.existsSync.mockReturnValue(true); + const { logInfo: envLogInfo } = await import('../lib/logger.js'); + envLogInfo('info-message'); + expect(logSpy).toHaveBeenCalledWith('[openai-codex-plugin] info-message'); }); - it('logWarn always logs', async () => { - const { logWarn } = await import('../lib/logger.js'); - logWarn('warning', { detail: 'info' }); - expect(warnSpy).toHaveBeenCalledWith('[openai-codex-plugin] warning {"detail":"info"}'); + it('persist failures log warnings and append entries', async () => { + fsMocks.existsSync.mockReturnValue(true); + fsMocks.writeFileSync.mockImplementation(() => { + throw new Error('boom'); }); + const { logRequest } = await import('../lib/logger.js'); - it('logWarn logs message without data', async () => { - const { logWarn } = await import('../lib/logger.js'); - warnSpy.mockClear(); - logWarn('just-message'); - expect(warnSpy).toHaveBeenCalledWith('[openai-codex-plugin] just-message'); - }); + logRequest('stage-two', { foo: 'bar' }); + expect(warnSpy).toHaveBeenCalledWith('[openai-codex-plugin] Failed to persist request log {"stage":"stage-two","error":"boom"}'); + expect(fsMocks.appendFileSync).toHaveBeenCalled(); + }); }); From 3976d2e206b2ec6ccb0eb1c8e0895fb3861e862c Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:02:57 -0600 Subject: [PATCH 14/22] test: add appendFileSync mock to plugin-config tests - Add missing appendFileSync mock to prevent test failures - Ensure all file system operations are properly mocked - Maintain test isolation and consistency --- test/plugin-config.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index 2698ffe..9db0d1c 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -9,6 +9,7 @@ vi.mock('node:fs', () => ({ readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), + appendFileSync: vi.fn(), })); // Get mocked functions From b5a2683fc54337bf15120edee0eabb3d0587fd0d Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:03:07 -0600 Subject: [PATCH 15/22] test: add appendFileSync mock to prompts-codex tests - Add appendFileSync mock to prevent test failures from logger changes - Clear all mocks properly in beforeEach setup - Ensure test isolation and consistency across test runs --- test/prompts-codex.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/prompts-codex.test.ts b/test/prompts-codex.test.ts index d6c2556..5b6015f 100644 --- a/test/prompts-codex.test.ts +++ b/test/prompts-codex.test.ts @@ -6,6 +6,7 @@ const files = new Map(); const existsSync = vi.fn((file: string) => files.has(file)); const readFileSync = vi.fn((file: string) => files.get(file) ?? ''); const writeFileSync = vi.fn((file: string, content: string) => files.set(file, content)); +const appendFileSync = vi.fn((file: string, content: string) => files.set(`${file}-rolling`, content)); const mkdirSync = vi.fn(); const homedirMock = vi.fn(() => '/mock-home'); const fetchMock = vi.fn(); @@ -15,11 +16,13 @@ vi.mock('node:fs', () => ({ existsSync, readFileSync, writeFileSync, + appendFileSync, mkdirSync, }, existsSync, readFileSync, writeFileSync, + appendFileSync, mkdirSync, })); @@ -38,13 +41,15 @@ beforeEach(() => { existsSync.mockClear(); readFileSync.mockClear(); writeFileSync.mockClear(); + appendFileSync.mockClear(); mkdirSync.mockClear(); homedirMock.mockReturnValue('/mock-home'); fetchMock.mockClear(); - global.fetch = fetchMock; + (global as any).fetch = fetchMock; codexInstructionsCache.clear(); }); + afterEach(() => { // Cleanup global fetch if needed delete (global as any).fetch; From 6f8cf66613f3345103a275335cad1e6835047ff1 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:03:16 -0600 Subject: [PATCH 16/22] test: add comprehensive fs mocks to prompts-opencode-codex tests - Add existsSync, appendFileSync, writeFileSync, mkdirSync mocks - Clear all mocks in beforeEach for proper test isolation - Prevent test failures from logger persistent logging changes - Ensure consistent test environment across all test files --- test/prompts-opencode-codex.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/prompts-opencode-codex.test.ts b/test/prompts-opencode-codex.test.ts index bec44bd..e407ee4 100644 --- a/test/prompts-opencode-codex.test.ts +++ b/test/prompts-opencode-codex.test.ts @@ -10,6 +10,10 @@ const homedirMock = vi.fn(() => '/mock-home'); const fetchMock = vi.fn(); const recordCacheHitMock = vi.fn(); const recordCacheMissMock = vi.fn(); +const existsSync = vi.fn(() => false); +const appendFileSync = vi.fn(); +const writeFileSync = vi.fn(); +const mkdirSync = vi.fn(); vi.mock('node:fs/promises', () => ({ mkdir: mkdirMock, @@ -17,6 +21,19 @@ vi.mock('node:fs/promises', () => ({ writeFile: writeFileMock, })); +vi.mock('node:fs', () => ({ + default: { + existsSync, + appendFileSync, + writeFileSync, + mkdirSync, + }, + existsSync, + appendFileSync, + writeFileSync, + mkdirSync, +})); + vi.mock('node:os', () => ({ __esModule: true, homedir: homedirMock, @@ -50,6 +67,10 @@ describe('OpenCode Codex Prompt Fetcher', () => { fetchMock.mockClear(); recordCacheHitMock.mockClear(); recordCacheMissMock.mockClear(); + existsSync.mockReset(); + appendFileSync.mockReset(); + writeFileSync.mockReset(); + mkdirSync.mockReset(); openCodePromptCache.clear(); vi.stubGlobal('fetch', fetchMock); }); From bd06f6e21984a24ea5547f59906229e04e424b52 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:03:23 -0600 Subject: [PATCH 17/22] test: add comprehensive gpt-5.1-codex-max test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add model normalization tests for all codex-max variants - Test xhigh reasoning effort behavior for codex-max vs other models - Verify reasoning effort downgrade logic (minimal/none → low, xhigh → high) - Add integration tests for transformRequestBody with xhigh reasoning - Ensure complete test coverage for new Codex Max functionality --- test/request-transformer.test.ts | 63 ++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index e3a0e99..1e49880 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -62,6 +62,13 @@ describe('normalizeModel', () => { expect(normalizeModel('openai/codex-mini-latest')).toBe('gpt-5.1-codex-mini'); }); + it('should normalize codex max variants to gpt-5.1-codex-max', async () => { + expect(normalizeModel('gpt-5.1-codex-max')).toBe('gpt-5.1-codex-max'); + expect(normalizeModel('gpt51-codex-max')).toBe('gpt-5.1-codex-max'); + expect(normalizeModel('gpt-5-codex-max')).toBe('gpt-5.1-codex-max'); + expect(normalizeModel('codex-max')).toBe('gpt-5.1-codex-max'); + }); + it('should normalize gpt-5.1 general presets to gpt-5.1', async () => { expect(normalizeModel('gpt-5.1')).toBe('gpt-5.1'); expect(normalizeModel('gpt-5.1-medium')).toBe('gpt-5.1'); @@ -124,6 +131,32 @@ describe('getReasoningConfig (gpt-5.1)', () => { }); }); +describe('getReasoningConfig (gpt-5.1-codex-max)', () => { + it('defaults to medium and allows xhigh effort', async () => { + const defaults = getReasoningConfig('gpt-5.1-codex-max', {}); + expect(defaults.effort).toBe('medium'); + + const xhigh = getReasoningConfig('gpt-5.1-codex-max', { reasoningEffort: 'xhigh' }); + expect(xhigh.effort).toBe('xhigh'); + }); + + it('downgrades minimal or none to low for codex max', async () => { + const minimal = getReasoningConfig('gpt-5.1-codex-max', { reasoningEffort: 'minimal' }); + expect(minimal.effort).toBe('low'); + + const none = getReasoningConfig('gpt-5.1-codex-max', { reasoningEffort: 'none' }); + expect(none.effort).toBe('low'); + }); + + it('downgrades xhigh to high on other models', async () => { + const codex = getReasoningConfig('gpt-5.1-codex', { reasoningEffort: 'xhigh' }); + expect(codex.effort).toBe('high'); + + const general = getReasoningConfig('gpt-5', { reasoningEffort: 'xhigh' }); + expect(general.effort).toBe('high'); + }); +}); + describe('filterInput', () => { it('should handle null/undefined in filterInput', async () => { expect(filterInput(null as any)).toBeNull(); @@ -749,6 +782,36 @@ describe('transformRequestBody', () => { expect(result.reasoning?.summary).toBe('detailed'); }); + it('should keep xhigh reasoning effort for gpt-5.1-codex-max', async () => { + const body: RequestBody = { + model: 'gpt-5.1-codex-max', + input: [], + }; + const userConfig: UserConfig = { + global: { + reasoningEffort: 'xhigh', + }, + models: {}, + }; + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + expect(result.reasoning?.effort).toBe('xhigh'); + }); + + it('should downgrade xhigh reasoning for non-codex-max models', async () => { + const body: RequestBody = { + model: 'gpt-5.1-codex', + input: [], + }; + const userConfig: UserConfig = { + global: { + reasoningEffort: 'xhigh', + }, + models: {}, + }; + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + expect(result.reasoning?.effort).toBe('high'); + }); + it('should apply default text verbosity', async () => { const body: RequestBody = { model: 'gpt-5', From d451b3d70897f66acfa02de01f527d9e55370e74 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:03:40 -0600 Subject: [PATCH 18/22] docs: add specification files for gpt-5.1-codex-max and persistent logging - Add comprehensive spec for Codex Max integration with xhigh reasoning - Document persistent logging requirements and implementation plan - Track requirements, references, and change logs for both features --- spec/gpt-51-codex-max.md | 37 +++++++++++++++++++++++++++++++++++++ spec/persistent-logging.md | 26 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 spec/gpt-51-codex-max.md create mode 100644 spec/persistent-logging.md diff --git a/spec/gpt-51-codex-max.md b/spec/gpt-51-codex-max.md new file mode 100644 index 0000000..51d46f6 --- /dev/null +++ b/spec/gpt-51-codex-max.md @@ -0,0 +1,37 @@ +# Spec: GPT-5.1-Codex-Max integration + +## Context +Issue [open-hax/codex#26](https://github.com/open-hax/codex/issues/26) introduces the new `gpt-5.1-codex-max` model, which replaces `gpt-5.1-codex` as the default Codex surface and adds the "Extra High" (`xhigh`) reasoning effort tier. The current `codex-auth` plugin only normalizes `gpt-5.1`, `gpt-5.1-codex`, and `gpt-5.1-codex-mini` variants (`lib/request/request-transformer.ts:303-426`) and exposes reasoning tiers up to `high` (`lib/types.ts:36-50`, `test/request-transformer.test.ts:15-125`). Documentation (`AGENTS.md:6-111`, `README.md:93-442`, `docs/development/CONFIG_FIELDS.md:288-310`) and bundled configs (`config/full-opencode.json:18-150`, `config/minimal-opencode.json:1-32`) still describe `gpt-5.1-codex` as the flagship choice. We must align with the Codex CLI reference implementation (`codex-cli/codex-rs/common/src/model_presets.rs:53-107`) which already treats `gpt-5.1-codex-max` as the default preset and only exposes the `xhigh` reasoning option for this model. + +## References +- Issue: [open-hax/codex#26](https://github.com/open-hax/codex/issues/26) +- Request transformer logic: `lib/request/request-transformer.ts:303-426`, `lib/request/request-transformer.ts:825-955` +- Type definitions: `lib/types.ts:36-50` +- Tests: `test/request-transformer.test.ts:15-1450` +- Docs & config samples: `AGENTS.md:6-111`, `README.md:93-442`, `docs/development/CONFIG_FIELDS.md:288-310`, `config/full-opencode.json:18-150`, `config/minimal-opencode.json:1-32` +- Reference behavior: `codex-cli/codex-rs/common/src/model_presets.rs:53-131` (default reasoning options for Codex Max) + +## Requirements / Definition of Done +1. `normalizeModel()` must map `gpt-5.1-codex-max` and all aliases (`gpt51-codex-max`, `codex-max`, `gpt-5-codex-max`, etc.) to the canonical `gpt-5.1-codex-max` slug, prioritizing this match above the existing `gpt-5.1-codex` checks. +2. `ConfigOptions` and `ReasoningConfig` types must allow the new `"xhigh"` reasoning effort, and `getReasoningConfig()` must: + - Default `gpt-5.1-codex-max` to `medium` effort, mirroring Codex CLI presets. + - Accept `xhigh` only when the original model maps to `gpt-5.1-codex-max`; other models requesting `xhigh` should gracefully downgrade (e.g., to `high`). + - Preserve existing clamps for Codex Mini, legacy Codex, and lightweight GPT-5 variants. +3. `transformRequestBody()` must preserve Codex CLI defaults for GPT-5.1-Codex-Max requests (text verbosity `medium`, no parallel tool calls) and continue merging per-model overrides from user config. +4. Automated tests must cover: + - Normalization of new slug variants. + - Reasoning clamps/defaults for Codex Max, including `xhigh` acceptance and rejection for other families. + - `transformRequestBody()` behavior when `reasoningEffort: "xhigh"` is set for Codex Max vs. non-supported models. +5. Documentation and sample configs must describe `gpt-5.1-codex-max` as the new default and explain the `xhigh` reasoning tier where reasoning levels are enumerated. +6. Update change tracking (this spec + final summary) and ensure all tests (`npm test`) pass. + +## Plan +1. Update `lib/types.ts` to extend the reasoning effort union with `"xhigh"`, then adjust `normalizeModel()`/`getReasoningConfig()` in `lib/request/request-transformer.ts` for the new slug ordering, default effort, and `xhigh` gate. +2. Enhance `transformRequestBody()` logic/tests to verify reasoning selections involving `gpt-5.1-codex-max`, ensuring Codex models still disable parallel tool calls. +3. Add regression tests in `test/request-transformer.test.ts` (normalization, reasoning, integration) to cover Codex Max inputs and `xhigh` handling. +4. Refresh docs/config samples (`AGENTS.md`, `README.md`, `docs/development/CONFIG_FIELDS.md`, `config/*.json`) to mention Codex Max as the default Codex tier and introduce the `xhigh` effort level. +5. Run the full test suite (`npm test`) and capture results; document completion in this spec's change log and final response. + +## Change Log +- 2025-11-19: Initial spec drafted for GPT-5.1-Codex-Max normalization, reasoning, tests, and docs. +- 2025-11-19: Added Codex Max normalization, `xhigh` gating, tests, and documentation/config updates mirroring the Codex CLI rollout. diff --git a/spec/persistent-logging.md b/spec/persistent-logging.md new file mode 100644 index 0000000..f51ebbf --- /dev/null +++ b/spec/persistent-logging.md @@ -0,0 +1,26 @@ +# Spec: Persistent Logger Defaults + +## Context +Tests emit many console lines because `logRequest`, `logWarn`, and other helpers write directly to stdout/stderr unless `ENABLE_PLUGIN_REQUEST_LOGGING` is disabled. The harness request is to keep test output quiet while still retaining full request telemetry: "Let's just always log to a file both in tests, and in production." Currently `lib/logger.ts` only writes JSON request stages when `ENABLE_PLUGIN_REQUEST_LOGGING=1` (see `logRequest` around lines 47-65). Debug logs are also suppressed unless `DEBUG_CODEX_PLUGIN` is set, which means the only persistent record is console spam. We need a file-first logger that always captures request/response metadata without cluttering unit tests or production stdout. + +## References +- Logger implementation: `lib/logger.ts:1-149` +- Logger tests: `test/logger.test.ts:1-132` +- Testing guide (mentions logging expectations): `docs/development/TESTING.md:1-200` + +## Requirements / Definition of Done +1. `logRequest` must always persist per-request JSON files under `~/.opencode/logs/codex-plugin/` regardless of env vars, while console output remains opt-in (`ENABLE_PLUGIN_REQUEST_LOGGING` or `DEBUG_CODEX_PLUGIN` to mirror current behavior for stdout). +2. `logDebug`, `logInfo`, `logWarn`, and `logError` should write to a rolling log file (one per session/date is acceptable) *and* continue to emit to stdout/stderr only when the corresponding env var enables it. The file logs should capture level, timestamp, and context to simplify search. +3. Logger tests must cover the new default behavior (file writes happen without env vars, console output stays silent). Add regression coverage for both request-stage JSONs and the new aggregate log file. +4. Documentation (`docs/development/TESTING.md` or README logging section if present) must mention that logs are always written to `~/.opencode/logs/codex-plugin/` and how to enable console mirroring via env vars. +5. Ensure file logging uses ASCII/JSON content and is resilient when directories are missing (auto-create). Console noise in `npm test` should drop as a result. + +## Plan +1. Update `lib/logger.ts`: remove `LOGGING_ENABLED` gating for persistence, introduce helper(s) for writing request JSON + append-only log file; gate console emission using env flags. Reuse existing `ensureLogDir()` logic. +2. Extend logger tests to cover default persistence, console gating, and append log behavior. Mock fs to inspect file writes without touching disk. +3. Refresh docs to describe the new always-on file logging and optional console mirrors. Mention location + env toggles for developer reference. +4. Run `npm test` to ensure the quieter logging still passes and the new tests cover the behavior. + +## Change Log +- 2025-11-19: Drafted spec for persistent logger defaults per user request. +- 2025-11-19: Implemented always-on file logging, rolling log file, console gating, updated tests, and documentation. From e3144f817ea1d58e8f80dea4a11b78f672ccf59e Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:18:47 -0600 Subject: [PATCH 19/22] fix failing tests --- test/request-transformer.test.ts | 1491 ++++++++++++++++-------------- 1 file changed, 805 insertions(+), 686 deletions(-) diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 1e49880..9b591ee 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; import { normalizeModel, getModelConfig, @@ -8,733 +8,831 @@ import { isOpenCodeSystemPrompt, filterOpenCodeSystemPrompts, addCodexBridgeMessage, - transformRequestBody, -} from '../lib/request/request-transformer.js'; -import type { RequestBody, UserConfig, InputItem } from '../lib/types.js'; + transformRequestBody as transformRequestBodyInternal, +} from "../lib/request/request-transformer.js"; +import type { RequestBody, UserConfig, InputItem } from "../lib/types.js"; -describe('normalizeModel', () => { - it('should normalize gpt-5', async () => { - expect(normalizeModel('gpt-5')).toBe('gpt-5'); +const transformRequestBody = async (...args: Parameters) => { + const result = await transformRequestBodyInternal(...args); + return result.body; +}; + +describe("normalizeModel", () => { + it("should normalize gpt-5", async () => { + expect(normalizeModel("gpt-5")).toBe("gpt-5"); }); it('should normalize variants containing "codex"', async () => { - expect(normalizeModel('openai/gpt-5-codex')).toBe('gpt-5-codex'); - expect(normalizeModel('custom-gpt-5-codex-variant')).toBe('gpt-5-codex'); + expect(normalizeModel("openai/gpt-5-codex")).toBe("gpt-5-codex"); + expect(normalizeModel("custom-gpt-5-codex-variant")).toBe("gpt-5-codex"); }); it('should normalize variants containing "gpt-5"', async () => { - expect(normalizeModel('gpt-5-mini')).toBe('gpt-5'); - expect(normalizeModel('gpt-5-nano')).toBe('gpt-5'); + expect(normalizeModel("gpt-5-mini")).toBe("gpt-5"); + expect(normalizeModel("gpt-5-nano")).toBe("gpt-5"); }); - it('should return gpt-5.1 as default for unknown models', async () => { - expect(normalizeModel('unknown-model')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-4')).toBe('gpt-5.1'); + it("should return gpt-5.1 as default for unknown models", async () => { + expect(normalizeModel("unknown-model")).toBe("gpt-5.1"); + expect(normalizeModel("gpt-4")).toBe("gpt-5.1"); }); - it('should return gpt-5.1 for undefined', async () => { - expect(normalizeModel(undefined)).toBe('gpt-5.1'); + it("should return gpt-5.1 for undefined", async () => { + expect(normalizeModel(undefined)).toBe("gpt-5.1"); }); - it('should normalize all gpt-5 presets to gpt-5', async () => { - expect(normalizeModel('gpt-5-minimal')).toBe('gpt-5'); - expect(normalizeModel('gpt-5-low')).toBe('gpt-5'); - expect(normalizeModel('gpt-5-medium')).toBe('gpt-5'); - expect(normalizeModel('gpt-5-high')).toBe('gpt-5'); + it("should normalize all gpt-5 presets to gpt-5", async () => { + expect(normalizeModel("gpt-5-minimal")).toBe("gpt-5"); + expect(normalizeModel("gpt-5-low")).toBe("gpt-5"); + expect(normalizeModel("gpt-5-medium")).toBe("gpt-5"); + expect(normalizeModel("gpt-5-high")).toBe("gpt-5"); }); - it('should prioritize codex over gpt-5 in model name', async () => { + it("should prioritize codex over gpt-5 in model name", async () => { // Model name contains BOTH "codex" and "gpt-5" // Should return "gpt-5-codex" (codex checked first) - expect(normalizeModel('gpt-5-codex-low')).toBe('gpt-5-codex'); - expect(normalizeModel('my-gpt-5-codex-model')).toBe('gpt-5-codex'); + expect(normalizeModel("gpt-5-codex-low")).toBe("gpt-5-codex"); + expect(normalizeModel("my-gpt-5-codex-model")).toBe("gpt-5-codex"); }); - it('should normalize codex mini presets to gpt-5.1-codex-mini', async () => { - expect(normalizeModel('gpt-5-codex-mini')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5-codex-mini-medium')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5-codex-mini-high')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('openai/gpt-5-codex-mini-high')).toBe('gpt-5.1-codex-mini'); + it("should normalize codex mini presets to gpt-5.1-codex-mini", async () => { + expect(normalizeModel("gpt-5-codex-mini")).toBe("gpt-5.1-codex-mini"); + expect(normalizeModel("gpt-5-codex-mini-medium")).toBe("gpt-5.1-codex-mini"); + expect(normalizeModel("gpt-5-codex-mini-high")).toBe("gpt-5.1-codex-mini"); + expect(normalizeModel("openai/gpt-5-codex-mini-high")).toBe("gpt-5.1-codex-mini"); }); - it('should normalize raw codex-mini-latest slug to gpt-5.1-codex-mini', async () => { - expect(normalizeModel('codex-mini-latest')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('openai/codex-mini-latest')).toBe('gpt-5.1-codex-mini'); + it("should normalize raw codex-mini-latest slug to gpt-5.1-codex-mini", async () => { + expect(normalizeModel("codex-mini-latest")).toBe("gpt-5.1-codex-mini"); + expect(normalizeModel("openai/codex-mini-latest")).toBe("gpt-5.1-codex-mini"); }); - it('should normalize codex max variants to gpt-5.1-codex-max', async () => { - expect(normalizeModel('gpt-5.1-codex-max')).toBe('gpt-5.1-codex-max'); - expect(normalizeModel('gpt51-codex-max')).toBe('gpt-5.1-codex-max'); - expect(normalizeModel('gpt-5-codex-max')).toBe('gpt-5.1-codex-max'); - expect(normalizeModel('codex-max')).toBe('gpt-5.1-codex-max'); + it("should normalize codex max variants to gpt-5.1-codex-max", async () => { + expect(normalizeModel("gpt-5.1-codex-max")).toBe("gpt-5.1-codex-max"); + expect(normalizeModel("gpt51-codex-max")).toBe("gpt-5.1-codex-max"); + expect(normalizeModel("gpt-5-codex-max")).toBe("gpt-5.1-codex-max"); + expect(normalizeModel("codex-max")).toBe("gpt-5.1-codex-max"); }); - it('should normalize gpt-5.1 general presets to gpt-5.1', async () => { - expect(normalizeModel('gpt-5.1')).toBe('gpt-5.1'); - expect(normalizeModel('gpt-5.1-medium')).toBe('gpt-5.1'); - expect(normalizeModel('gpt51-high')).toBe('gpt-5.1'); - expect(normalizeModel('gpt 5.1 none')).toBe('gpt-5.1'); + it("should normalize gpt-5.1 general presets to gpt-5.1", async () => { + expect(normalizeModel("gpt-5.1")).toBe("gpt-5.1"); + expect(normalizeModel("gpt-5.1-medium")).toBe("gpt-5.1"); + expect(normalizeModel("gpt51-high")).toBe("gpt-5.1"); + expect(normalizeModel("gpt 5.1 none")).toBe("gpt-5.1"); }); - it('should normalize gpt-5.1 codex presets to gpt-5.1-codex', async () => { - expect(normalizeModel('gpt-5.1-codex-low')).toBe('gpt-5.1-codex'); - expect(normalizeModel('gpt51-codex')).toBe('gpt-5.1-codex'); - expect(normalizeModel('openai/gpt-5.1-codex-high')).toBe('gpt-5.1-codex'); + it("should normalize gpt-5.1 codex presets to gpt-5.1-codex", async () => { + expect(normalizeModel("gpt-5.1-codex-low")).toBe("gpt-5.1-codex"); + expect(normalizeModel("gpt51-codex")).toBe("gpt-5.1-codex"); + expect(normalizeModel("openai/gpt-5.1-codex-high")).toBe("gpt-5.1-codex"); }); - it('should normalize gpt-5.1 codex mini presets to gpt-5.1-codex-mini', async () => { - expect(normalizeModel('gpt-5.1-codex-mini')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt-5.1-codex-mini-medium')).toBe('gpt-5.1-codex-mini'); - expect(normalizeModel('gpt51-codex-mini-high')).toBe('gpt-5.1-codex-mini'); + it("should normalize gpt-5.1 codex mini presets to gpt-5.1-codex-mini", async () => { + expect(normalizeModel("gpt-5.1-codex-mini")).toBe("gpt-5.1-codex-mini"); + expect(normalizeModel("gpt-5.1-codex-mini-medium")).toBe("gpt-5.1-codex-mini"); + expect(normalizeModel("gpt51-codex-mini-high")).toBe("gpt-5.1-codex-mini"); }); - it('should handle mixed case', async () => { - expect(normalizeModel('Gpt-5-Codex-Low')).toBe('gpt-5-codex'); - expect(normalizeModel('GpT-5-MeDiUm')).toBe('gpt-5'); + it("should handle mixed case", async () => { + expect(normalizeModel("Gpt-5-Codex-Low")).toBe("gpt-5-codex"); + expect(normalizeModel("GpT-5-MeDiUm")).toBe("gpt-5"); }); - it('should handle special characters', async () => { - expect(normalizeModel('my_gpt-5_codex')).toBe('gpt-5-codex'); - expect(normalizeModel('gpt.5.high')).toBe('gpt-5'); + it("should handle special characters", async () => { + expect(normalizeModel("my_gpt-5_codex")).toBe("gpt-5-codex"); + expect(normalizeModel("gpt.5.high")).toBe("gpt-5"); }); - it('should handle old verbose names', async () => { - expect(normalizeModel('GPT 5 Codex Low (ChatGPT Subscription)')).toBe('gpt-5-codex'); - expect(normalizeModel('GPT 5 High (ChatGPT Subscription)')).toBe('gpt-5'); + it("should handle old verbose names", async () => { + expect(normalizeModel("GPT 5 Codex Low (ChatGPT Subscription)")).toBe("gpt-5-codex"); + expect(normalizeModel("GPT 5 High (ChatGPT Subscription)")).toBe("gpt-5"); }); - it('should handle empty string', async () => { - expect(normalizeModel('')).toBe('gpt-5.1'); + it("should handle empty string", async () => { + expect(normalizeModel("")).toBe("gpt-5.1"); }); }); -describe('getReasoningConfig (gpt-5.1)', () => { - it('defaults gpt-5.1 to none when no overrides are provided', async () => { - const result = getReasoningConfig('gpt-5.1', {}); - expect(result.effort).toBe('none'); - expect(result.summary).toBe('auto'); +describe("getReasoningConfig (gpt-5.1)", () => { + it("defaults gpt-5.1 to none when no overrides are provided", async () => { + const result = getReasoningConfig("gpt-5.1", {}); + expect(result.effort).toBe("none"); + expect(result.summary).toBe("auto"); }); - it('maps unsupported none effort to low for gpt-5.1-codex', async () => { - const result = getReasoningConfig('gpt-5.1-codex', { reasoningEffort: 'none' }); - expect(result.effort).toBe('low'); + it("maps unsupported none effort to low for gpt-5.1-codex", async () => { + const result = getReasoningConfig("gpt-5.1-codex", { reasoningEffort: "none" }); + expect(result.effort).toBe("low"); }); - it('enforces medium minimum effort for gpt-5.1-codex-mini', async () => { - const result = getReasoningConfig('gpt-5.1-codex-mini', { reasoningEffort: 'low' }); - expect(result.effort).toBe('medium'); + it("enforces medium minimum effort for gpt-5.1-codex-mini", async () => { + const result = getReasoningConfig("gpt-5.1-codex-mini", { reasoningEffort: "low" }); + expect(result.effort).toBe("medium"); }); - it('downgrades none to minimal on legacy gpt-5 models', async () => { - const result = getReasoningConfig('gpt-5', { reasoningEffort: 'none' }); - expect(result.effort).toBe('minimal'); + it("downgrades none to minimal on legacy gpt-5 models", async () => { + const result = getReasoningConfig("gpt-5", { reasoningEffort: "none" }); + expect(result.effort).toBe("minimal"); }); }); -describe('getReasoningConfig (gpt-5.1-codex-max)', () => { - it('defaults to medium and allows xhigh effort', async () => { - const defaults = getReasoningConfig('gpt-5.1-codex-max', {}); - expect(defaults.effort).toBe('medium'); +describe("getReasoningConfig (gpt-5.1-codex-max)", () => { + it("defaults to medium and allows xhigh effort", async () => { + const defaults = getReasoningConfig("gpt-5.1-codex-max", {}); + expect(defaults.effort).toBe("medium"); - const xhigh = getReasoningConfig('gpt-5.1-codex-max', { reasoningEffort: 'xhigh' }); - expect(xhigh.effort).toBe('xhigh'); + const xhigh = getReasoningConfig("gpt-5.1-codex-max", { reasoningEffort: "xhigh" }); + expect(xhigh.effort).toBe("xhigh"); }); - it('downgrades minimal or none to low for codex max', async () => { - const minimal = getReasoningConfig('gpt-5.1-codex-max', { reasoningEffort: 'minimal' }); - expect(minimal.effort).toBe('low'); + it("downgrades minimal or none to low for codex max", async () => { + const minimal = getReasoningConfig("gpt-5.1-codex-max", { reasoningEffort: "minimal" }); + expect(minimal.effort).toBe("low"); - const none = getReasoningConfig('gpt-5.1-codex-max', { reasoningEffort: 'none' }); - expect(none.effort).toBe('low'); + const none = getReasoningConfig("gpt-5.1-codex-max", { reasoningEffort: "none" }); + expect(none.effort).toBe("low"); }); - it('downgrades xhigh to high on other models', async () => { - const codex = getReasoningConfig('gpt-5.1-codex', { reasoningEffort: 'xhigh' }); - expect(codex.effort).toBe('high'); + it("downgrades xhigh to high on other models", async () => { + const codex = getReasoningConfig("gpt-5.1-codex", { reasoningEffort: "xhigh" }); + expect(codex.effort).toBe("high"); - const general = getReasoningConfig('gpt-5', { reasoningEffort: 'xhigh' }); - expect(general.effort).toBe('high'); + const general = getReasoningConfig("gpt-5", { reasoningEffort: "xhigh" }); + expect(general.effort).toBe("high"); }); }); -describe('filterInput', () => { - it('should handle null/undefined in filterInput', async () => { +describe("filterInput", () => { + it("should handle null/undefined in filterInput", async () => { expect(filterInput(null as any)).toBeNull(); expect(filterInput(undefined)).toBeUndefined(); expect(filterInput([])).toEqual([]); }); - it('should handle malformed input in filterInput', async () => { + it("should handle malformed input in filterInput", async () => { const malformedInput = { notAnArray: true } as any; expect(filterInput(malformedInput)).toBe(malformedInput); }); - it('should keep items without IDs unchanged', async () => { - const input: InputItem[] = [ - { type: 'message', role: 'user', content: 'hello' }, - ]; + it("should keep items without IDs unchanged", async () => { + const input: InputItem[] = [{ type: "message", role: "user", content: "hello" }]; const result = filterInput(input); expect(result).toEqual(input); - expect(result![0]).not.toHaveProperty('id'); + expect(result![0]).not.toHaveProperty("id"); }); - it('should remove ALL message IDs (rs_, msg_, etc.) for store:false compatibility', async () => { + it("should remove ALL message IDs (rs_, msg_, etc.) for store:false compatibility", async () => { const input: InputItem[] = [ - { id: 'rs_123', type: 'message', role: 'assistant', content: 'hello' }, - { id: 'msg_456', type: 'message', role: 'user', content: 'world' }, - { id: 'assistant_789', type: 'message', role: 'assistant', content: 'test' }, + { id: "rs_123", type: "message", role: "assistant", content: "hello" }, + { id: "msg_456", type: "message", role: "user", content: "world" }, + { id: "assistant_789", type: "message", role: "assistant", content: "test" }, ]; const result = filterInput(input); // All items should remain (no filtering), but ALL IDs removed expect(result).toHaveLength(3); - expect(result![0]).not.toHaveProperty('id'); - expect(result![1]).not.toHaveProperty('id'); - expect(result![2]).not.toHaveProperty('id'); - expect(result![0].content).toBe('hello'); - expect(result![1].content).toBe('world'); - expect(result![2].content).toBe('test'); + expect(result![0]).not.toHaveProperty("id"); + expect(result![1]).not.toHaveProperty("id"); + expect(result![2]).not.toHaveProperty("id"); + expect(result![0].content).toBe("hello"); + expect(result![1].content).toBe("world"); + expect(result![2].content).toBe("test"); }); - it('removes metadata when normalizing stateless input', async () => { + it("removes metadata when normalizing stateless input", async () => { const input: InputItem[] = [ { - id: 'msg_123', - type: 'message', - role: 'user', - content: 'test', - metadata: { some: 'data' } + id: "msg_123", + type: "message", + role: "user", + content: "test", + metadata: { some: "data" }, }, ]; const result = filterInput(input); expect(result).toHaveLength(1); - expect(result![0]).not.toHaveProperty('id'); - expect(result![0].type).toBe('message'); - expect(result![0].role).toBe('user'); - expect(result![0].content).toBe('test'); - expect(result![0]).not.toHaveProperty('metadata'); + expect(result![0]).not.toHaveProperty("id"); + expect(result![0].type).toBe("message"); + expect(result![0].role).toBe("user"); + expect(result![0].content).toBe("test"); + expect(result![0]).not.toHaveProperty("metadata"); }); - it('preserves metadata when IDs are preserved for host caching', async () => { + it("preserves metadata when IDs are preserved for host caching", async () => { const input: InputItem[] = [ { - id: 'msg_123', - type: 'message', - role: 'user', - content: 'test', - metadata: { some: 'data' } + id: "msg_123", + type: "message", + role: "user", + content: "test", + metadata: { some: "data" }, }, ]; const result = filterInput(input, { preserveIds: true }); expect(result).toHaveLength(1); - expect(result![0]).toHaveProperty('id', 'msg_123'); - expect(result![0]).toHaveProperty('metadata'); + expect(result![0]).toHaveProperty("id", "msg_123"); + expect(result![0]).toHaveProperty("metadata"); }); - it('should handle mixed items with and without IDs', async () => { + it("should handle mixed items with and without IDs", async () => { const input: InputItem[] = [ - { type: 'message', role: 'user', content: '1' }, - { id: 'rs_stored', type: 'message', role: 'assistant', content: '2' }, - { id: 'msg_123', type: 'message', role: 'user', content: '3' }, + { type: "message", role: "user", content: "1" }, + { id: "rs_stored", type: "message", role: "assistant", content: "2" }, + { id: "msg_123", type: "message", role: "user", content: "3" }, ]; const result = filterInput(input); // All items kept, IDs removed from items that had them expect(result).toHaveLength(3); - expect(result![0]).not.toHaveProperty('id'); - expect(result![1]).not.toHaveProperty('id'); - expect(result![2]).not.toHaveProperty('id'); - expect(result![0].content).toBe('1'); - expect(result![1].content).toBe('2'); - expect(result![2].content).toBe('3'); + expect(result![0]).not.toHaveProperty("id"); + expect(result![1]).not.toHaveProperty("id"); + expect(result![2]).not.toHaveProperty("id"); + expect(result![0].content).toBe("1"); + expect(result![1].content).toBe("2"); + expect(result![2].content).toBe("3"); }); - it('should handle custom ID formats (future-proof)', async () => { + it("should handle custom ID formats (future-proof)", async () => { const input: InputItem[] = [ - { id: 'custom_id_format', type: 'message', role: 'user', content: 'test' }, - { id: 'another-format-123', type: 'message', role: 'user', content: 'test2' }, + { id: "custom_id_format", type: "message", role: "user", content: "test" }, + { id: "another-format-123", type: "message", role: "user", content: "test2" }, ]; const result = filterInput(input); expect(result).toHaveLength(2); - expect(result![0]).not.toHaveProperty('id'); - expect(result![1]).not.toHaveProperty('id'); + expect(result![0]).not.toHaveProperty("id"); + expect(result![1]).not.toHaveProperty("id"); }); - it('should return undefined for undefined input', async () => { + it("should return undefined for undefined input", async () => { expect(filterInput(undefined)).toBeUndefined(); }); - it('should return non-array input as-is', async () => { + it("should return non-array input as-is", async () => { const notArray = { notAnArray: true }; expect(filterInput(notArray as any)).toBe(notArray); }); - it('should handle empty array', async () => { + it("should handle empty array", async () => { const input: InputItem[] = []; const result = filterInput(input); expect(result).toEqual([]); }); }); -describe('getModelConfig', () => { - describe('Per-model options (Bug Fix Verification)', () => { - it('should find per-model options using config key', async () => { +describe("getModelConfig", () => { + describe("Per-model options (Bug Fix Verification)", () => { + it("should find per-model options using config key", async () => { const userConfig: UserConfig = { - global: { reasoningEffort: 'medium' }, + global: { reasoningEffort: "medium" }, models: { - 'gpt-5-codex-low': { - options: { reasoningEffort: 'low', textVerbosity: 'low' } - } - } + "gpt-5-codex-low": { + options: { reasoningEffort: "low", textVerbosity: "low" }, + }, + }, }; - const result = getModelConfig('gpt-5-codex-low', userConfig); - expect(result.reasoningEffort).toBe('low'); - expect(result.textVerbosity).toBe('low'); + const result = getModelConfig("gpt-5-codex-low", userConfig); + expect(result.reasoningEffort).toBe("low"); + expect(result.textVerbosity).toBe("low"); }); - it('should merge global and per-model options (per-model wins)', async () => { + it("should merge global and per-model options (per-model wins)", async () => { const userConfig: UserConfig = { global: { - reasoningEffort: 'medium', - textVerbosity: 'medium', - include: ['reasoning.encrypted_content'] + reasoningEffort: "medium", + textVerbosity: "medium", + include: ["reasoning.encrypted_content"], }, models: { - 'gpt-5-codex-high': { - options: { reasoningEffort: 'high' } // Override only effort - } - } + "gpt-5-codex-high": { + options: { reasoningEffort: "high" }, // Override only effort + }, + }, }; - const result = getModelConfig('gpt-5-codex-high', userConfig); - expect(result.reasoningEffort).toBe('high'); // From per-model - expect(result.textVerbosity).toBe('medium'); // From global - expect(result.include).toEqual(['reasoning.encrypted_content']); // From global + const result = getModelConfig("gpt-5-codex-high", userConfig); + expect(result.reasoningEffort).toBe("high"); // From per-model + expect(result.textVerbosity).toBe("medium"); // From global + expect(result.include).toEqual(["reasoning.encrypted_content"]); // From global }); - it('should return global options when model not in config', async () => { + it("should return global options when model not in config", async () => { const userConfig: UserConfig = { - global: { reasoningEffort: 'medium' }, + global: { reasoningEffort: "medium" }, models: { - 'gpt-5-codex-low': { options: { reasoningEffort: 'low' } } - } + "gpt-5-codex-low": { options: { reasoningEffort: "low" } }, + }, }; // Looking up different model - const result = getModelConfig('gpt-5-codex', userConfig); - expect(result.reasoningEffort).toBe('medium'); // Global only + const result = getModelConfig("gpt-5-codex", userConfig); + expect(result.reasoningEffort).toBe("medium"); // Global only }); - it('should handle empty config', async () => { - const result = getModelConfig('gpt-5-codex', { global: {}, models: {} }); + it("should handle empty config", async () => { + const result = getModelConfig("gpt-5-codex", { global: {}, models: {} }); expect(result).toEqual({}); }); - it('should handle missing models object', async () => { + it("should handle missing models object", async () => { const userConfig: UserConfig = { - global: { reasoningEffort: 'low' }, - models: undefined as any + global: { reasoningEffort: "low" }, + models: undefined as any, }; - const result = getModelConfig('gpt-5', userConfig); - expect(result.reasoningEffort).toBe('low'); + const result = getModelConfig("gpt-5", userConfig); + expect(result.reasoningEffort).toBe("low"); }); - it('should handle boundary conditions in getModelConfig', async () => { + it("should handle boundary conditions in getModelConfig", async () => { // Test with empty models object const userConfig: UserConfig = { - global: { reasoningEffort: 'high' }, - models: {} as any + global: { reasoningEffort: "high" }, + models: {} as any, }; - const result = getModelConfig('gpt-5-codex', userConfig); - expect(result.reasoningEffort).toBe('high'); + const result = getModelConfig("gpt-5-codex", userConfig); + expect(result.reasoningEffort).toBe("high"); }); - it('should handle undefined global config in getModelConfig', async () => { + it("should handle undefined global config in getModelConfig", async () => { const userConfig: UserConfig = { global: undefined as any, - models: {} + models: {}, }; - const result = getModelConfig('gpt-5', userConfig); + const result = getModelConfig("gpt-5", userConfig); expect(result).toEqual({}); }); }); - describe('Backwards compatibility', () => { - it('should work with old verbose config keys', async () => { + describe("Backwards compatibility", () => { + it("should work with old verbose config keys", async () => { const userConfig: UserConfig = { global: {}, models: { - 'GPT 5 Codex Low (ChatGPT Subscription)': { - options: { reasoningEffort: 'low' } - } - } + "GPT 5 Codex Low (ChatGPT Subscription)": { + options: { reasoningEffort: "low" }, + }, + }, }; - const result = getModelConfig('GPT 5 Codex Low (ChatGPT Subscription)', userConfig); - expect(result.reasoningEffort).toBe('low'); + const result = getModelConfig("GPT 5 Codex Low (ChatGPT Subscription)", userConfig); + expect(result.reasoningEffort).toBe("low"); }); - it('should work with old configs that have id field', async () => { + it("should work with old configs that have id field", async () => { const userConfig: UserConfig = { global: {}, models: { - 'gpt-5-codex-low': ({ - id: 'gpt-5-codex', // id field present but should be ignored - options: { reasoningEffort: 'low' } - } as any) - } + "gpt-5-codex-low": { + id: "gpt-5-codex", // id field present but should be ignored + options: { reasoningEffort: "low" }, + } as any, + }, }; - const result = getModelConfig('gpt-5-codex-low', userConfig); - expect(result.reasoningEffort).toBe('low'); + const result = getModelConfig("gpt-5-codex-low", userConfig); + expect(result.reasoningEffort).toBe("low"); }); }); - describe('Default models (no custom config)', () => { - it('should return global options for default gpt-5-codex', async () => { + describe("Default models (no custom config)", () => { + it("should return global options for default gpt-5-codex", async () => { const userConfig: UserConfig = { - global: { reasoningEffort: 'high' }, - models: {} + global: { reasoningEffort: "high" }, + models: {}, }; - const result = getModelConfig('gpt-5-codex', userConfig); - expect(result.reasoningEffort).toBe('high'); + const result = getModelConfig("gpt-5-codex", userConfig); + expect(result.reasoningEffort).toBe("high"); }); - it('should return empty when no config at all', async () => { - const result = getModelConfig('gpt-5', undefined); + it("should return empty when no config at all", async () => { + const result = getModelConfig("gpt-5", undefined); expect(result).toEqual({}); }); }); }); -describe('addToolRemapMessage', () => { - it('should prepend tool remap message when tools present', async () => { - const input: InputItem[] = [ - { type: 'message', role: 'user', content: 'hello' }, - ]; +describe("addToolRemapMessage", () => { + it("should prepend tool remap message when tools present", async () => { + const input: InputItem[] = [{ type: "message", role: "user", content: "hello" }]; const result = addToolRemapMessage(input, true); expect(result).toHaveLength(2); - expect(result![0].role).toBe('developer'); - expect(result![0].type).toBe('message'); - expect((result![0].content as any)[0].text).toContain('apply_patch'); + expect(result![0].role).toBe("developer"); + expect(result![0].type).toBe("message"); + expect((result![0].content as any)[0].text).toContain("apply_patch"); }); - it('should not modify input when tools not present', async () => { - const input: InputItem[] = [ - { type: 'message', role: 'user', content: 'hello' }, - ]; + it("should not modify input when tools not present", async () => { + const input: InputItem[] = [{ type: "message", role: "user", content: "hello" }]; const result = addToolRemapMessage(input, false); expect(result).toEqual(input); }); - it('should return undefined for undefined input', async () => { + it("should return undefined for undefined input", async () => { expect(addToolRemapMessage(undefined, true)).toBeUndefined(); }); - it('should handle non-array input', async () => { + it("should handle non-array input", async () => { const notArray = { notAnArray: true }; expect(addToolRemapMessage(notArray as any, true)).toBe(notArray); }); }); -describe('isOpenCodeSystemPrompt', () => { - it('should detect OpenCode system prompt with string content', async () => { +describe("isOpenCodeSystemPrompt", () => { + it("should detect OpenCode system prompt with string content", async () => { const item: InputItem = { - type: 'message', - role: 'developer', - content: 'You are a coding agent running in OpenCode', + type: "message", + role: "developer", + content: "You are a coding agent running in OpenCode", }; expect(isOpenCodeSystemPrompt(item, null)).toBe(true); }); - it('should detect OpenCode system prompt with array content', async () => { + it("should detect OpenCode system prompt with array content", async () => { const item: InputItem = { - type: 'message', - role: 'developer', + type: "message", + role: "developer", content: [ { - type: 'input_text', - text: 'You are a coding agent running in OpenCode', + type: "input_text", + text: "You are a coding agent running in OpenCode", }, ], }; expect(isOpenCodeSystemPrompt(item, null)).toBe(true); }); - it('should detect with system role', async () => { + it("should detect with system role", async () => { const item: InputItem = { - type: 'message', - role: 'system', - content: 'You are a coding agent running in OpenCode', + type: "message", + role: "system", + content: "You are a coding agent running in OpenCode", }; expect(isOpenCodeSystemPrompt(item, null)).toBe(true); }); - it('should not detect non-system roles', async () => { + it("should not detect non-system roles", async () => { const item: InputItem = { - type: 'message', - role: 'user', - content: 'You are a coding agent running in OpenCode', + type: "message", + role: "user", + content: "You are a coding agent running in OpenCode", }; expect(isOpenCodeSystemPrompt(item, null)).toBe(false); }); - it('should not detect different content', async () => { + it("should not detect different content", async () => { const item: InputItem = { - type: 'message', - role: 'developer', - content: 'Different message', + type: "message", + role: "developer", + content: "Different message", }; expect(isOpenCodeSystemPrompt(item, null)).toBe(false); }); - it('should NOT detect AGENTS.md content', async () => { + it("should NOT detect AGENTS.md content", async () => { const item: InputItem = { - type: 'message', - role: 'developer', - content: '# Project Guidelines\n\nThis is custom AGENTS.md content for the project.', + type: "message", + role: "developer", + content: "# Project Guidelines\n\nThis is custom AGENTS.md content for the project.", }; expect(isOpenCodeSystemPrompt(item, null)).toBe(false); }); - it('should NOT detect environment info concatenated with AGENTS.md', async () => { + it("should NOT detect environment info concatenated with AGENTS.md", async () => { const item: InputItem = { - type: 'message', - role: 'developer', - content: 'Environment: /path/to/project\nDate: 2025-01-01\n\n# AGENTS.md\n\nCustom instructions here.', + type: "message", + role: "developer", + content: "Environment: /path/to/project\nDate: 2025-01-01\n\n# AGENTS.md\n\nCustom instructions here.", }; expect(isOpenCodeSystemPrompt(item, null)).toBe(false); }); - it('should NOT detect content with codex signature in the middle', async () => { - const cachedPrompt = 'You are a coding agent running in OpenCode.'; + it("should NOT detect content with codex signature in the middle", async () => { + const cachedPrompt = "You are a coding agent running in OpenCode."; const item: InputItem = { - type: 'message', - role: 'developer', + type: "message", + role: "developer", // Has codex.txt content but with environment prepended (like OpenCode does) - content: 'Environment info here\n\nYou are a coding agent running in OpenCode.', + content: "Environment info here\n\nYou are a coding agent running in OpenCode.", }; // First 200 chars won't match because of prepended content expect(isOpenCodeSystemPrompt(item, cachedPrompt)).toBe(false); }); - it('should detect with cached prompt exact match', async () => { - const cachedPrompt = 'You are a coding agent running in OpenCode'; + it("should detect with cached prompt exact match", async () => { + const cachedPrompt = "You are a coding agent running in OpenCode"; const item: InputItem = { - type: 'message', - role: 'developer', - content: 'You are a coding agent running in OpenCode', + type: "message", + role: "developer", + content: "You are a coding agent running in OpenCode", }; expect(isOpenCodeSystemPrompt(item, cachedPrompt)).toBe(true); }); }); -describe('filterOpenCodeSystemPrompts', () => { - it('should filter out OpenCode system prompts', async () => { +describe("filterOpenCodeSystemPrompts", () => { + it("should filter out OpenCode system prompts", async () => { const input: InputItem[] = [ { - type: 'message', - role: 'developer', - content: 'You are a coding agent running in OpenCode', + type: "message", + role: "developer", + content: "You are a coding agent running in OpenCode", }, - { type: 'message', role: 'user', content: 'hello' }, + { type: "message", role: "user", content: "hello" }, ]; const result = await filterOpenCodeSystemPrompts(input); expect(result).toHaveLength(1); - expect(result![0].role).toBe('user'); + expect(result![0].role).toBe("user"); }); - it('should keep user messages', async () => { + it("should keep user messages", async () => { const input: InputItem[] = [ - { type: 'message', role: 'user', content: 'message 1' }, - { type: 'message', role: 'user', content: 'message 2' }, + { type: "message", role: "user", content: "message 1" }, + { type: "message", role: "user", content: "message 2" }, ]; const result = await filterOpenCodeSystemPrompts(input); expect(result).toHaveLength(2); }); - it('should keep non-OpenCode developer messages', async () => { + it("should keep non-OpenCode developer messages", async () => { const input: InputItem[] = [ - { type: 'message', role: 'developer', content: 'Custom instruction' }, - { type: 'message', role: 'user', content: 'hello' }, + { type: "message", role: "developer", content: "Custom instruction" }, + { type: "message", role: "user", content: "hello" }, ]; const result = await filterOpenCodeSystemPrompts(input); expect(result).toHaveLength(2); }); - it('should keep AGENTS.md content (not filter it)', async () => { + it("should keep AGENTS.md content (not filter it)", async () => { const input: InputItem[] = [ { - type: 'message', - role: 'developer', - content: 'You are a coding agent running in OpenCode', // This is codex.txt + type: "message", + role: "developer", + content: "You are a coding agent running in OpenCode", // This is codex.txt }, { - type: 'message', - role: 'developer', - content: '# Project Guidelines\n\nThis is AGENTS.md content.', // This is AGENTS.md + type: "message", + role: "developer", + content: "# Project Guidelines\n\nThis is AGENTS.md content.", // This is AGENTS.md }, - { type: 'message', role: 'user', content: 'hello' }, + { type: "message", role: "user", content: "hello" }, ]; const result = await filterOpenCodeSystemPrompts(input); // Should filter codex.txt but keep AGENTS.md expect(result).toHaveLength(2); - expect(result![0].content).toContain('AGENTS.md'); - expect(result![1].role).toBe('user'); + expect(result![0].content).toContain("AGENTS.md"); + expect(result![1].role).toBe("user"); }); - it('should keep environment+AGENTS.md concatenated message', async () => { + it("should keep environment+AGENTS.md concatenated message", async () => { const input: InputItem[] = [ { - type: 'message', - role: 'developer', - content: 'You are a coding agent running in OpenCode', // codex.txt alone + type: "message", + role: "developer", + content: "You are a coding agent running in OpenCode", // codex.txt alone }, { - type: 'message', - role: 'developer', + type: "message", + role: "developer", // environment + AGENTS.md joined (like OpenCode does) - content: 'Working directory: /path/to/project\nDate: 2025-01-01\n\n# AGENTS.md\n\nCustom instructions.', + content: + "Working directory: /path/to/project\nDate: 2025-01-01\n\n# AGENTS.md\n\nCustom instructions.", }, - { type: 'message', role: 'user', content: 'hello' }, + { type: "message", role: "user", content: "hello" }, ]; const result = await filterOpenCodeSystemPrompts(input); // Should filter first message (codex.txt) but keep second (env+AGENTS.md) expect(result).toHaveLength(2); - expect(result![0].content).toContain('AGENTS.md'); - expect(result![1].role).toBe('user'); + expect(result![0].content).toContain("AGENTS.md"); + expect(result![1].role).toBe("user"); }); - it('should return undefined for undefined input', async () => { + it("should return undefined for undefined input", async () => { expect(await filterOpenCodeSystemPrompts(undefined)).toBeUndefined(); }); }); -describe('addCodexBridgeMessage', () => { - it('should prepend bridge message when tools present', async () => { - const input = [ - { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'test' }] }, - ]; +describe("addCodexBridgeMessage", () => { + it("should prepend bridge message when tools present", async () => { + const input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "test" }] }]; const result = addCodexBridgeMessage(input, true); expect(result).toHaveLength(2); - expect(result![0].role).toBe('developer'); - expect(result![0].type).toBe('message'); - expect((result![0].content as any)[0].text).toContain('Codex in OpenCode'); + expect(result![0].role).toBe("developer"); + expect(result![0].type).toBe("message"); + expect((result![0].content as any)[0].text).toContain("Codex in OpenCode"); }); - it('should not modify input when tools not present', async () => { - const input: InputItem[] = [ - { type: 'message', role: 'user', content: 'hello' }, - ]; + it("should not modify input when tools not present", async () => { + const input: InputItem[] = [{ type: "message", role: "user", content: "hello" }]; const result = addCodexBridgeMessage(input, false); expect(result).toEqual(input); }); - it('should return undefined for undefined input', async () => { + it("should return undefined for undefined input", async () => { expect(addCodexBridgeMessage(undefined, true)).toBeUndefined(); }); }); -describe('transformRequestBody', () => { - const codexInstructions = 'Test Codex Instructions'; +describe("transformRequestBody", () => { + const codexInstructions = "Test Codex Instructions"; - it('preserves existing prompt_cache_key passed by host (OpenCode)', async () => { + it("preserves existing prompt_cache_key passed by host (OpenCode)", async () => { const body: RequestBody = { - model: 'gpt-5-codex', + model: "gpt-5-codex", input: [], // Host-provided key (OpenCode session id) // host-provided field is allowed by plugin - prompt_cache_key: 'ses_host_key_123', + prompt_cache_key: "ses_host_key_123", }; const result: any = await transformRequestBody(body, codexInstructions); - expect(result.prompt_cache_key).toBe('ses_host_key_123'); + expect(result.prompt_cache_key).toBe("ses_host_key_123"); }); - it('preserves promptCacheKey (camelCase) from host', async () => { + it("preserves promptCacheKey (camelCase) from host", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], - promptCacheKey: 'ses_camel_key_456', + promptCacheKey: "ses_camel_key_456", }; const result: any = await transformRequestBody(body, codexInstructions); - expect(result.prompt_cache_key).toBe('ses_camel_key_456'); + expect(result.prompt_cache_key).toBe("ses_camel_key_456"); }); - it('derives prompt_cache_key from metadata when host omits one', async () => { + it("derives prompt_cache_key from metadata when host omits one", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], - metadata: { conversation_id: 'meta-conv-123' }, + metadata: { conversation_id: "meta-conv-123" }, }; const result: any = await transformRequestBody(body, codexInstructions); - expect(result.prompt_cache_key).toBe('meta-conv-123'); + expect(result.prompt_cache_key).toBe("cache_meta-conv-123"); + }); + + it("derives fork-aware prompt_cache_key when fork id is present in metadata", async () => { + const body: RequestBody = { + model: "gpt-5", + metadata: { + conversation_id: "meta-conv-123", + forkId: "branch-1", + }, + input: [], + } as any; + const result: any = await transformRequestBody(body, codexInstructions); + expect(result.prompt_cache_key).toBe("cache_meta-conv-123-fork-branch-1"); }); - it('derives fork-aware prompt_cache_key when fork id is present in metadata', async () => { + it("derives fork-aware prompt_cache_key when fork id is present in root", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", + conversation_id: "meta-conv-123", + fork_id: "branch-2", input: [], - metadata: { conversation_id: 'meta-conv-123', forkId: 'branch-1' }, + } as any; + const result: any = await transformRequestBody(body, codexInstructions); + expect(result.prompt_cache_key).toBe("cache_meta-conv-123-fork-branch-2"); + }); + + it("reuses the same prompt_cache_key across non-structural overrides", async () => { + const baseBody: RequestBody = { + model: "gpt-5", + metadata: { + conversation_id: "meta-conv-789", + forkId: "fork-x", + }, + input: [], + } as any; + const body1: RequestBody = { ...baseBody } as RequestBody; + const body2: RequestBody = { ...baseBody, text: { verbosity: "low" as const } } as RequestBody; + + const result1: any = await transformRequestBody(body1, codexInstructions); + const result2: any = await transformRequestBody(body2, codexInstructions); + + expect(result1.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); + expect(result2.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); + }); + + it("derives fork-aware prompt_cache_key when fork id is present in metadata", async () => { + const body: RequestBody = { + model: "gpt-5", + input: [], + metadata: { conversation_id: "meta-conv-123", forkId: "branch-1" }, }; const result: any = await transformRequestBody(body, codexInstructions); - expect(result.prompt_cache_key).toBe('meta-conv-123::fork::branch-1'); + expect(result.prompt_cache_key).toBe("cache_meta-conv-123-fork-branch-1"); + }); + + it("derives fork-aware prompt_cache_key when fork id is present in root", async () => { + const body: RequestBody = { + model: "gpt-5", + input: [], + metadata: { conversation_id: "meta-conv-123" }, + forkId: "branch-2" as any, + } as any; + const result: any = await transformRequestBody(body, codexInstructions); + expect(result.prompt_cache_key).toBe("cache_meta-conv-123-fork-branch-2"); + }); + + it("reuses the same prompt_cache_key across non-structural overrides", async () => { + const baseMetadata = { conversation_id: "meta-conv-789", forkId: "fork-x" }; + const body1: RequestBody = { + model: "gpt-5", + input: [], + metadata: { ...baseMetadata }, + }; + const body2: RequestBody = { + model: "gpt-5", + input: [], + metadata: { ...baseMetadata }, + // Soft overrides that should not change the cache key + max_output_tokens: 1024, + reasoning: { effort: "high" } as any, + text: { verbosity: "high" } as any, + }; + + const result1: any = await transformRequestBody(body1, codexInstructions); + const result2: any = await transformRequestBody(body2, codexInstructions); + + expect(result1.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); + expect(result2.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); }); - it('derives fork-aware prompt_cache_key when fork id is present in root', async () => { + it("derives fork-aware prompt_cache_key when fork id is present in root", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], - metadata: { conversation_id: 'meta-conv-123' }, - forkId: 'branch-2' as any, + metadata: { conversation_id: "meta-conv-123" }, + forkId: "branch-2" as any, } as any; const result: any = await transformRequestBody(body, codexInstructions); - expect(result.prompt_cache_key).toBe('meta-conv-123::fork::branch-2'); + expect(result.prompt_cache_key).toBe("cache_meta-conv-123-fork-branch-2"); + }); + + it("reuses the same prompt_cache_key across non-structural overrides", async () => { + const baseMetadata = { conversation_id: "meta-conv-789", forkId: "fork-x" }; + const body1: RequestBody = { + model: "gpt-5", + input: [], + metadata: { ...baseMetadata }, + }; + const body2: RequestBody = { + model: "gpt-5", + input: [], + metadata: { ...baseMetadata }, + // Soft overrides that should not change the cache key + max_output_tokens: 1024, + reasoning: { effort: "high" } as any, + text: { verbosity: "high" } as any, + }; + + const result1: any = await transformRequestBody(body1, codexInstructions); + const result2: any = await transformRequestBody(body2, codexInstructions); + + expect(result1.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); + expect(result2.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); }); - it('reuses the same prompt_cache_key across non-structural overrides', async () => { - const baseMetadata = { conversation_id: 'meta-conv-789', forkId: 'fork-x' }; + it("reuses the same prompt_cache_key across non-structural overrides", async () => { + const baseMetadata = { conversation_id: "meta-conv-789", forkId: "fork-x" }; const body1: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], metadata: { ...baseMetadata }, }; const body2: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], metadata: { ...baseMetadata }, // Soft overrides that should not change the cache key max_output_tokens: 1024, - reasoning: { effort: 'high' } as any, - text: { verbosity: 'high' } as any, + reasoning: { effort: "high" } as any, + text: { verbosity: "high" } as any, }; const result1: any = await transformRequestBody(body1, codexInstructions); const result2: any = await transformRequestBody(body2, codexInstructions); - expect(result1.prompt_cache_key).toBe('meta-conv-789::fork::fork-x'); - expect(result2.prompt_cache_key).toBe('meta-conv-789::fork::fork-x'); + expect(result1.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); + expect(result2.prompt_cache_key).toBe("cache_meta-conv-789-fork-fork-x"); }); - it('generates fallback prompt_cache_key when no identifiers exist', async () => { + it("generates fallback prompt_cache_key when no identifiers exist", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const result: any = await transformRequestBody(body, codexInstructions); - expect(typeof result.prompt_cache_key).toBe('string'); + expect(typeof result.prompt_cache_key).toBe("string"); expect(result.prompt_cache_key).toMatch(/^cache_/); }); - it('should set required Codex fields', async () => { + it("should set required Codex fields", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const result = await transformRequestBody(body, codexInstructions); @@ -744,186 +842,198 @@ describe('transformRequestBody', () => { expect(result.instructions).toBe(codexInstructions); }); - it('should normalize model name', async () => { + it("should normalize model name", async () => { const body: RequestBody = { - model: 'gpt-5-mini', + model: "gpt-5-mini", input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.model).toBe('gpt-5'); + expect(result.model).toBe("gpt-5"); }); - it('should apply default reasoning config', async () => { + it("should apply default reasoning config", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.reasoning?.effort).toBe('medium'); - expect(result.reasoning?.summary).toBe('auto'); + expect(result.reasoning?.effort).toBe("medium"); + expect(result.reasoning?.summary).toBe("auto"); }); - it('should apply user reasoning config', async () => { + it("should apply user reasoning config", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const userConfig: UserConfig = { global: { - reasoningEffort: 'high', - reasoningSummary: 'detailed', + reasoningEffort: "high", + reasoningSummary: "detailed", }, models: {}, }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); - expect(result.reasoning?.effort).toBe('high'); - expect(result.reasoning?.summary).toBe('detailed'); + expect(result.reasoning?.effort).toBe("high"); + expect(result.reasoning?.summary).toBe("detailed"); }); - it('should keep xhigh reasoning effort for gpt-5.1-codex-max', async () => { + it("should keep xhigh reasoning effort for gpt-5.1-codex-max", async () => { const body: RequestBody = { - model: 'gpt-5.1-codex-max', + model: "gpt-5.1-codex-max", input: [], }; const userConfig: UserConfig = { global: { - reasoningEffort: 'xhigh', + reasoningEffort: "xhigh", }, models: {}, }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); - expect(result.reasoning?.effort).toBe('xhigh'); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); + expect(result.reasoning?.effort).toBe("xhigh"); }); - it('should downgrade xhigh reasoning for non-codex-max models', async () => { + it("should downgrade xhigh reasoning for non-codex-max models", async () => { const body: RequestBody = { - model: 'gpt-5.1-codex', + model: "gpt-5.1-codex", input: [], }; const userConfig: UserConfig = { global: { - reasoningEffort: 'xhigh', + reasoningEffort: "xhigh", }, models: {}, }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); - expect(result.reasoning?.effort).toBe('high'); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); + expect(result.reasoning?.effort).toBe("high"); }); - it('should apply default text verbosity', async () => { + it("should apply default text verbosity", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.text?.verbosity).toBe('medium'); + expect(result.text?.verbosity).toBe("medium"); }); - it('should apply user text verbosity', async () => { + it("should apply user text verbosity", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const userConfig: UserConfig = { - global: { textVerbosity: 'low' }, + global: { textVerbosity: "low" }, models: {}, }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); - expect(result.text?.verbosity).toBe('low'); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); + expect(result.text?.verbosity).toBe("low"); }); - it('should set default include for encrypted reasoning', async () => { + it("should set default include for encrypted reasoning", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.include).toEqual(['reasoning.encrypted_content']); + expect(result.include).toEqual(["reasoning.encrypted_content"]); }); - it('should use user-configured include', async () => { + it("should use user-configured include", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const userConfig: UserConfig = { - global: { include: ['custom_field', 'reasoning.encrypted_content'] }, + global: { include: ["custom_field", "reasoning.encrypted_content"] }, models: {}, }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); - expect(result.include).toEqual(['custom_field', 'reasoning.encrypted_content']); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); + expect(result.include).toEqual(["custom_field", "reasoning.encrypted_content"]); }); - it('should remove IDs from input array (keep all items, strip IDs)', async () => { + it("should remove IDs from input array (keep all items, strip IDs)", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [ - { id: 'rs_123', type: 'message', role: 'assistant', content: 'old' }, - { type: 'message', role: 'user', content: 'new' }, + { id: "rs_123", type: "message", role: "assistant", content: "old" }, + { type: "message", role: "user", content: "new" }, ], }; const result = await transformRequestBody(body, codexInstructions); // All items kept, IDs removed expect(result.input).toHaveLength(2); - expect(result.input![0]).not.toHaveProperty('id'); - expect(result.input![1]).not.toHaveProperty('id'); - expect(result.input![0].content).toBe('old'); - expect(result.input![1].content).toBe('new'); + expect(result.input![0]).not.toHaveProperty("id"); + expect(result.input![1]).not.toHaveProperty("id"); + expect(result.input![0].content).toBe("old"); + expect(result.input![1].content).toBe("new"); }); - it('should preserve IDs when preserveIds option is set', async () => { + it("should preserve IDs when preserveIds option is set", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [ - { id: 'msg_1', type: 'message', role: 'user', content: 'hello' }, - { id: 'call_1', type: 'function_call', role: 'assistant' }, + { id: "msg_1", type: "message", role: "user", content: "hello" }, + { id: "call_1", type: "function_call", role: "assistant" }, ], }; - const result = await transformRequestBody(body, codexInstructions, undefined, true, { preserveIds: true }); + const result = await transformRequestBody(body, codexInstructions, undefined, true, { + preserveIds: true, + }); expect(result.input).toHaveLength(2); - expect(result.input?.[0].id).toBe('msg_1'); - expect(result.input?.[1].id).toBe('call_1'); + expect(result.input?.[0].id).toBe("msg_1"); + expect(result.input?.[1].id).toBe("call_1"); }); - it('should prioritize snake_case cache key when both fields present', async () => { + it("should prioritize snake_case cache key when both fields present", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'hello' }], - promptCacheKey: 'camelcase-key', - prompt_cache_key: 'snakecase-key', + model: "gpt-5", + input: [{ type: "message", role: "user", content: "hello" }], + promptCacheKey: "camelcase-key", + prompt_cache_key: "snakecase-key", }; const result = await transformRequestBody(body, codexInstructions); // Should prioritize snake_case over camelCase - expect(result.prompt_cache_key).toBe('snakecase-key'); + expect(result.prompt_cache_key).toBe("snakecase-key"); }); - it('should add tool remap message when tools present', async () => { + it("should add tool remap message when tools present", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'hello' }], - tools: [{ name: 'test_tool' }], + model: "gpt-5", + input: [{ type: "message", role: "user", content: "hello" }], + tools: [{ name: "test_tool" }], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.input![0].role).toBe('developer'); + expect(result.input![0].role).toBe("developer"); }); - it('should not add tool remap message when tools absent', async () => { + it("should not add tool remap message when tools absent", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'hello' }], + model: "gpt-5", + input: [{ type: "message", role: "user", content: "hello" }], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.input![0].role).toBe('user'); + expect(result.input![0].role).toBe("user"); }); - it('should remove unsupported parameters', async () => { + it("should remove unsupported parameters", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], max_output_tokens: 1000, max_completion_tokens: 2000, @@ -933,404 +1043,424 @@ describe('transformRequestBody', () => { expect(result.max_completion_tokens).toBeUndefined(); }); - it('should normalize minimal to low for gpt-5-codex', async () => { + it("should normalize minimal to low for gpt-5-codex", async () => { const body: RequestBody = { - model: 'gpt-5-codex', + model: "gpt-5-codex", input: [], }; const userConfig: UserConfig = { - global: { reasoningEffort: 'minimal' }, + global: { reasoningEffort: "minimal" }, models: {}, }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); - expect(result.reasoning?.effort).toBe('low'); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); + expect(result.reasoning?.effort).toBe("low"); }); - it('should preserve minimal for non-codex models', async () => { + it("should preserve minimal for non-codex models", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const userConfig: UserConfig = { - global: { reasoningEffort: 'minimal' }, + global: { reasoningEffort: "minimal" }, models: {}, }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); - expect(result.reasoning?.effort).toBe('minimal'); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); + expect(result.reasoning?.effort).toBe("minimal"); }); - it('should use minimal effort for lightweight models', async () => { + it("should use minimal effort for lightweight models", async () => { const body: RequestBody = { - model: 'gpt-5-nano', + model: "gpt-5-nano", input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.reasoning?.effort).toBe('minimal'); + expect(result.reasoning?.effort).toBe("minimal"); }); - describe('CODEX_MODE parameter', () => { - it('should use bridge message when codexMode=true and tools present (default)', async () => { + describe("CODEX_MODE parameter", () => { + it("should use bridge message when codexMode=true and tools present (default)", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'hello' }], - tools: [{ name: 'test_tool' }], + model: "gpt-5", + input: [{ type: "message", role: "user", content: "hello" }], + tools: [{ name: "test_tool" }], }; const result = await transformRequestBody(body, codexInstructions, undefined, true); expect(result.input).toHaveLength(2); - expect(result.input![0].role).toBe('developer'); - expect((result.input![0].content as any)[0].text).toContain('Codex in OpenCode'); + expect(result.input![0].role).toBe("developer"); + expect((result.input![0].content as any)[0].text).toContain("Codex in OpenCode"); }); - it('should filter OpenCode prompts when codexMode=true', async () => { + it("should filter OpenCode prompts when codexMode=true", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [ { - type: 'message', - role: 'developer', - content: 'You are a coding agent running in OpenCode', + type: "message", + role: "developer", + content: "You are a coding agent running in OpenCode", }, - { type: 'message', role: 'user', content: 'hello' }, + { type: "message", role: "user", content: "hello" }, ], - tools: [{ name: 'test_tool' }], + tools: [{ name: "test_tool" }], }; const result = await transformRequestBody(body, codexInstructions, undefined, true); // Should have bridge message + user message (OpenCode prompt filtered out) expect(result.input).toHaveLength(2); - expect(result.input![0].role).toBe('developer'); - expect((result.input![0].content as any)[0].text).toContain('Codex in OpenCode'); - expect(result.input![1].role).toBe('user'); + expect(result.input![0].role).toBe("developer"); + expect((result.input![0].content as any)[0].text).toContain("Codex in OpenCode"); + expect(result.input![1].role).toBe("user"); }); - it('should not add bridge message when codexMode=true but no tools', async () => { + it("should not add bridge message when codexMode=true but no tools", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'hello' }], + model: "gpt-5", + input: [{ type: "message", role: "user", content: "hello" }], }; const result = await transformRequestBody(body, codexInstructions, undefined, true); expect(result.input).toHaveLength(1); - expect(result.input![0].role).toBe('user'); + expect(result.input![0].role).toBe("user"); }); - it('should use tool remap message when codexMode=false', async () => { + it("should use tool remap message when codexMode=false", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'hello' }], - tools: [{ name: 'test_tool' }], + model: "gpt-5", + input: [{ type: "message", role: "user", content: "hello" }], + tools: [{ name: "test_tool" }], }; const result = await transformRequestBody(body, codexInstructions, undefined, false); expect(result.input).toHaveLength(2); - expect(result.input![0].role).toBe('developer'); - expect((result.input![0].content as any)[0].text).toContain('apply_patch'); + expect(result.input![0].role).toBe("developer"); + expect((result.input![0].content as any)[0].text).toContain("apply_patch"); }); - it('should not filter OpenCode prompts when codexMode=false', async () => { + it("should not filter OpenCode prompts when codexMode=false", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [ { - type: 'message', - role: 'developer', - content: 'You are a coding agent running in OpenCode', + type: "message", + role: "developer", + content: "You are a coding agent running in OpenCode", }, - { type: 'message', role: 'user', content: 'hello' }, + { type: "message", role: "user", content: "hello" }, ], - tools: [{ name: 'test_tool' }], + tools: [{ name: "test_tool" }], }; const result = await transformRequestBody(body, codexInstructions, undefined, false); // Should have tool remap + opencode prompt + user message expect(result.input).toHaveLength(3); - expect(result.input![0].role).toBe('developer'); - expect((result.input![0].content as any)[0].text).toContain('apply_patch'); - expect(result.input![1].role).toBe('developer'); - expect(result.input![2].role).toBe('user'); + expect(result.input![0].role).toBe("developer"); + expect((result.input![0].content as any)[0].text).toContain("apply_patch"); + expect(result.input![1].role).toBe("developer"); + expect(result.input![2].role).toBe("user"); }); - it('should default to codexMode=true when parameter not provided', async () => { + it("should default to codexMode=true when parameter not provided", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'hello' }], - tools: [{ name: 'test_tool' }], + model: "gpt-5", + input: [{ type: "message", role: "user", content: "hello" }], + tools: [{ name: "test_tool" }], }; // Not passing codexMode parameter - should default to true const result = await transformRequestBody(body, codexInstructions); // Should use bridge message (codexMode=true by default) - expect(result.input![0].role).toBe('developer'); - expect((result.input![0].content as any)[0].text).toContain('Codex in OpenCode'); + expect(result.input![0].role).toBe("developer"); + expect((result.input![0].content as any)[0].text).toContain("Codex in OpenCode"); }); }); // NEW: Integration tests for all config scenarios - describe('Integration: Complete Config Scenarios', () => { - describe('Scenario 1: Default models (no custom config)', () => { - it('should handle gpt-5-codex with global options only', async () => { + describe("Integration: Complete Config Scenarios", () => { + describe("Scenario 1: Default models (no custom config)", () => { + it("should handle gpt-5-codex with global options only", async () => { const body: RequestBody = { - model: 'gpt-5-codex', - input: [] + model: "gpt-5-codex", + input: [], }; const userConfig: UserConfig = { - global: { reasoningEffort: 'high' }, - models: {} + global: { reasoningEffort: "high" }, + models: {}, }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); - expect(result.model).toBe('gpt-5-codex'); // Not changed - expect(result.reasoning?.effort).toBe('high'); // From global + expect(result.model).toBe("gpt-5-codex"); // Not changed + expect(result.reasoning?.effort).toBe("high"); // From global expect(result.store).toBe(false); }); - it('should handle gpt-5-mini normalizing to gpt-5', async () => { + it("should handle gpt-5-mini normalizing to gpt-5", async () => { const body: RequestBody = { - model: 'gpt-5-mini', - input: [] + model: "gpt-5-mini", + input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.model).toBe('gpt-5'); // Normalized - expect(result.reasoning?.effort).toBe('minimal'); // Lightweight default + expect(result.model).toBe("gpt-5"); // Normalized + expect(result.reasoning?.effort).toBe("minimal"); // Lightweight default }); }); - describe('Scenario 2: Custom preset names (new style)', () => { + describe("Scenario 2: Custom preset names (new style)", () => { const userConfig: UserConfig = { - global: { reasoningEffort: 'medium', include: ['reasoning.encrypted_content'] }, + global: { reasoningEffort: "medium", include: ["reasoning.encrypted_content"] }, models: { - 'gpt-5-codex-low': { - options: { reasoningEffort: 'low' } + "gpt-5-codex-low": { + options: { reasoningEffort: "low" }, + }, + "gpt-5-codex-high": { + options: { reasoningEffort: "high", reasoningSummary: "detailed" }, }, - 'gpt-5-codex-high': { - options: { reasoningEffort: 'high', reasoningSummary: 'detailed' } - } - } + }, }; - it('should apply per-model options for gpt-5-codex-low', async () => { + it("should apply per-model options for gpt-5-codex-low", async () => { const body: RequestBody = { - model: 'gpt-5-codex-low', - input: [] + model: "gpt-5-codex-low", + input: [], }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); - expect(result.model).toBe('gpt-5-codex'); // Normalized - expect(result.reasoning?.effort).toBe('low'); // From per-model - expect(result.include).toEqual(['reasoning.encrypted_content']); // From global + expect(result.model).toBe("gpt-5-codex"); // Normalized + expect(result.reasoning?.effort).toBe("low"); // From per-model + expect(result.include).toEqual(["reasoning.encrypted_content"]); // From global }); - it('should apply per-model options for gpt-5-codex-high', async () => { + it("should apply per-model options for gpt-5-codex-high", async () => { const body: RequestBody = { - model: 'gpt-5-codex-high', - input: [] + model: "gpt-5-codex-high", + input: [], }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); - expect(result.model).toBe('gpt-5-codex'); // Normalized - expect(result.reasoning?.effort).toBe('high'); // From per-model - expect(result.reasoning?.summary).toBe('detailed'); // From per-model + expect(result.model).toBe("gpt-5-codex"); // Normalized + expect(result.reasoning?.effort).toBe("high"); // From per-model + expect(result.reasoning?.summary).toBe("detailed"); // From per-model }); - it('should use global options for default gpt-5-codex', async () => { + it("should use global options for default gpt-5-codex", async () => { const body: RequestBody = { - model: 'gpt-5-codex', - input: [] + model: "gpt-5-codex", + input: [], }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); - expect(result.model).toBe('gpt-5-codex'); // Not changed - expect(result.reasoning?.effort).toBe('medium'); // From global (no per-model) + expect(result.model).toBe("gpt-5-codex"); // Not changed + expect(result.reasoning?.effort).toBe("medium"); // From global (no per-model) }); }); - describe('Scenario 3: Backwards compatibility (old verbose names)', () => { + describe("Scenario 3: Backwards compatibility (old verbose names)", () => { const userConfig: UserConfig = { global: {}, models: { - 'GPT 5 Codex Low (ChatGPT Subscription)': { - options: { reasoningEffort: 'low', textVerbosity: 'low' } - } - } + "GPT 5 Codex Low (ChatGPT Subscription)": { + options: { reasoningEffort: "low", textVerbosity: "low" }, + }, + }, }; - it('should find and apply old config format', async () => { + it("should find and apply old config format", async () => { const body: RequestBody = { - model: 'GPT 5 Codex Low (ChatGPT Subscription)', - input: [] + model: "GPT 5 Codex Low (ChatGPT Subscription)", + input: [], }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); - expect(result.model).toBe('gpt-5-codex'); // Normalized - expect(result.reasoning?.effort).toBe('low'); // From per-model (old format) - expect(result.text?.verbosity).toBe('low'); + expect(result.model).toBe("gpt-5-codex"); // Normalized + expect(result.reasoning?.effort).toBe("low"); // From per-model (old format) + expect(result.text?.verbosity).toBe("low"); }); }); - describe('Scenario 4: Mixed default + custom models', () => { + describe("Scenario 4: Mixed default + custom models", () => { const userConfig: UserConfig = { - global: { reasoningEffort: 'medium' }, + global: { reasoningEffort: "medium" }, models: { - 'gpt-5-codex-low': { - options: { reasoningEffort: 'low' } - } - } + "gpt-5-codex-low": { + options: { reasoningEffort: "low" }, + }, + }, }; - it('should use per-model for custom variant', async () => { + it("should use per-model for custom variant", async () => { const body: RequestBody = { - model: 'gpt-5-codex-low', - input: [] + model: "gpt-5-codex-low", + input: [], }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); - expect(result.reasoning?.effort).toBe('low'); // Per-model + expect(result.reasoning?.effort).toBe("low"); // Per-model }); - it('should use global for default model', async () => { + it("should use global for default model", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [] + model: "gpt-5", + input: [], }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); - expect(result.reasoning?.effort).toBe('medium'); // Global + expect(result.reasoning?.effort).toBe("medium"); // Global }); }); - describe('Scenario 5: Message ID filtering with multi-turn', () => { - it('should remove ALL IDs in multi-turn conversation', async () => { + describe("Scenario 5: Message ID filtering with multi-turn", () => { + it("should remove ALL IDs in multi-turn conversation", async () => { const body: RequestBody = { - model: 'gpt-5-codex', + model: "gpt-5-codex", input: [ - { id: 'msg_turn1', type: 'message', role: 'user', content: 'first' }, - { id: 'rs_response1', type: 'message', role: 'assistant', content: 'response' }, - { id: 'msg_turn2', type: 'message', role: 'user', content: 'second' }, - { id: 'assistant_123', type: 'message', role: 'assistant', content: 'reply' }, - ] + { id: "msg_turn1", type: "message", role: "user", content: "first" }, + { id: "rs_response1", type: "message", role: "assistant", content: "response" }, + { id: "msg_turn2", type: "message", role: "user", content: "second" }, + { id: "assistant_123", type: "message", role: "assistant", content: "reply" }, + ], }; const result = await transformRequestBody(body, codexInstructions); // All items kept, ALL IDs removed expect(result.input).toHaveLength(4); - expect(result.input!.every(item => !item.id)).toBe(true); - expect(result.store).toBe(false); // Stateless mode - expect(result.include).toEqual(['reasoning.encrypted_content']); + expect(result.input!.every((item) => !item.id)).toBe(true); + expect(result.store).toBe(false); // Stateless mode + expect(result.include).toEqual(["reasoning.encrypted_content"]); }); }); - describe('Scenario 6: Complete end-to-end transformation', () => { - it('should handle full transformation: custom model + IDs + tools', async () => { + describe("Scenario 6: Complete end-to-end transformation", () => { + it("should handle full transformation: custom model + IDs + tools", async () => { const userConfig: UserConfig = { - global: { include: ['reasoning.encrypted_content'] }, + global: { include: ["reasoning.encrypted_content"] }, models: { - 'gpt-5-codex-low': { + "gpt-5-codex-low": { options: { - reasoningEffort: 'low', - textVerbosity: 'low', - reasoningSummary: 'auto' - } - } - } + reasoningEffort: "low", + textVerbosity: "low", + reasoningSummary: "auto", + }, + }, + }, }; const body: RequestBody = { - model: 'gpt-5-codex-low', + model: "gpt-5-codex-low", input: [ - { id: 'msg_1', type: 'message', role: 'user', content: 'test' }, - { id: 'rs_2', type: 'message', role: 'assistant', content: 'reply' } + { id: "msg_1", type: "message", role: "user", content: "test" }, + { id: "rs_2", type: "message", role: "assistant", content: "reply" }, ], - tools: [{ name: 'edit' }] + tools: [{ name: "edit" }], }; - const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false }); + const result = await transformRequestBody(body, codexInstructions, userConfig, true, { + preserveIds: false, + }); // Model normalized - expect(result.model).toBe('gpt-5-codex'); + expect(result.model).toBe("gpt-5-codex"); // IDs removed - expect(result.input!.every(item => !item.id)).toBe(true); + expect(result.input!.every((item) => !item.id)).toBe(true); // Per-model options applied - expect(result.reasoning?.effort).toBe('low'); - expect(result.reasoning?.summary).toBe('auto'); - expect(result.text?.verbosity).toBe('low'); + expect(result.reasoning?.effort).toBe("low"); + expect(result.reasoning?.summary).toBe("auto"); + expect(result.text?.verbosity).toBe("low"); // Codex fields set expect(result.store).toBe(false); expect(result.stream).toBe(true); expect(result.instructions).toBe(codexInstructions); - expect(result.include).toEqual(['reasoning.encrypted_content']); + expect(result.include).toEqual(["reasoning.encrypted_content"]); }); }); }); - describe('Edge Cases and Error Handling', () => { - it('should handle empty input array', async () => { + describe("Edge Cases and Error Handling", () => { + it("should handle empty input array", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], }; const result = await transformRequestBody(body, codexInstructions); expect(result.input).toEqual([]); }); - it('should handle null input', async () => { + it("should handle null input", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: null as any, }; const result = await transformRequestBody(body, codexInstructions); expect(result.input).toBeNull(); }); - it('should handle undefined input', async () => { + it("should handle undefined input", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: undefined as any, }; const result = await transformRequestBody(body, codexInstructions); expect(result.input).toBeUndefined(); }); - it.skip('should handle malformed input items', async () => { + it.skip("should handle malformed input items", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [ null, undefined, - { type: 'message', role: 'user' }, // missing content - { not: 'a valid item' } as any, + { type: "message", role: "user" }, // missing content + { not: "a valid item" } as any, ], }; const result = await transformRequestBody(body, codexInstructions); expect(result.input).toHaveLength(4); }); - it('should handle content array with mixed types', async () => { + it("should handle content array with mixed types", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [ { - type: 'message', - role: 'user', + type: "message", + role: "user", content: [ - { type: 'input_text', text: 'text content' }, - { type: 'image', image_url: 'url' }, + { type: "input_text", text: "text content" }, + { type: "image", image_url: "url" }, null, undefined, - 'not an object', + "not an object", ], }, ], @@ -1340,170 +1470,159 @@ describe('transformRequestBody', () => { expect(Array.isArray(result.input![0].content)).toBe(true); }); - it('should handle very long model names', async () => { + it("should handle very long model names", async () => { const body: RequestBody = { - model: 'very-long-model-name-with-gpt-5-codex-and-extra-stuff', + model: "very-long-model-name-with-gpt-5-codex-and-extra-stuff", input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.model).toBe('gpt-5-codex'); + expect(result.model).toBe("gpt-5-codex"); }); - it('should handle model with special characters', async () => { + it("should handle model with special characters", async () => { const body: RequestBody = { - model: 'gpt-5-codex@v1.0#beta', + model: "gpt-5-codex@v1.0#beta", input: [], }; const result = await transformRequestBody(body, codexInstructions); - expect(result.model).toBe('gpt-5-codex'); + expect(result.model).toBe("gpt-5-codex"); }); - it('should handle empty string model', async () => { - const body: RequestBody = { - model: '', - input: [], - }; - const result = await transformRequestBody(body, codexInstructions); - expect(result.model).toBe('gpt-5.1'); - }); - + it("should handle empty string model", async () => { + const body: RequestBody = { + model: "", + input: [], + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.model).toBe("gpt-5.1"); + }); - it('should handle reasoning config edge cases', async () => { + it("should handle reasoning config edge cases", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], reasoning: { - effort: 'invalid' as any, + effort: "invalid" as any, summary: null as any, } as any, }; const result = await transformRequestBody(body, codexInstructions); // Should override with defaults - expect(result.reasoning?.effort).toBe('medium'); - expect(result.reasoning?.summary).toBe('auto'); + expect(result.reasoning?.effort).toBe("medium"); + expect(result.reasoning?.summary).toBe("auto"); }); - it('should handle text config edge cases', async () => { + it("should handle text config edge cases", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], text: { - verbosity: 'invalid' as any, + verbosity: "invalid" as any, } as any, }; const result = await transformRequestBody(body, codexInstructions); // Should override with defaults - expect(result.text?.verbosity).toBe('medium'); + expect(result.text?.verbosity).toBe("medium"); }); - it('should handle include field edge cases', async () => { + it("should handle include field edge cases", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], - include: ['invalid', 'field', null as any, undefined as any], + include: ["invalid", "field", null as any, undefined as any], }; const result = await transformRequestBody(body, codexInstructions); // Should override with defaults - expect(result.include).toEqual(['reasoning.encrypted_content']); + expect(result.include).toEqual(["reasoning.encrypted_content"]); }); - it.skip('should handle session manager edge cases', async () => { + it.skip("should handle session manager edge cases", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'test' }], + model: "gpt-5", + input: [{ type: "message", role: "user", content: "test" }], }; - + const mockSessionManager = { getContext: () => null, applyRequest: () => null, } as any; const result = await transformRequestBody( - body, - codexInstructions, - undefined, - true, - { preserveIds: false }, - mockSessionManager + body, + codexInstructions, + undefined, + true, + { preserveIds: false }, + mockSessionManager, ); - + expect(result).toBeDefined(); expect(result.input).toHaveLength(1); }); - it('should handle tools array edge cases', async () => { + it("should handle tools array edge cases", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'test' }], - tools: [ - null, - undefined, - { name: 'valid_tool' }, - 'not an object' as any, - ], + model: "gpt-5", + input: [{ type: "message", role: "user", content: "test" }], + tools: [null, undefined, { name: "valid_tool" }, "not an object" as any], }; const result = await transformRequestBody(body, codexInstructions); // Should still add bridge message since tools array exists expect(result.input).toHaveLength(2); - expect(result.input![0].role).toBe('developer'); + expect(result.input![0].role).toBe("developer"); }); - it('should handle empty tools array', async () => { + it("should handle empty tools array", async () => { const body: RequestBody = { - model: 'gpt-5', - input: [{ type: 'message', role: 'user', content: 'test' }], + model: "gpt-5", + input: [{ type: "message", role: "user", content: "test" }], tools: [], }; const result = await transformRequestBody(body, codexInstructions); // Should not add bridge message for empty tools array expect(result.input).toHaveLength(1); - expect(result.input![0].role).toBe('user'); + expect(result.input![0].role).toBe("user"); }); - it('should handle metadata edge cases', async () => { + it("should handle metadata edge cases", async () => { const body: RequestBody = { - model: 'gpt-5', + model: "gpt-5", input: [], - metadata: { - conversation_id: null, - extra: 'field', - nested: { id: 'value' }, - }, - }; - const result1 = await transformRequestBody(body, codexInstructions); - const firstKey = result1.prompt_cache_key; - // Should generate fallback cache key - expect(typeof firstKey).toBe('string'); - expect(firstKey).toMatch(/^cache_/); - - // Second transform of the same body should reuse the existing key - const result2 = await transformRequestBody(body, codexInstructions); - expect(result2.prompt_cache_key).toBe(firstKey); - }); - + metadata: { + conversation_id: null, + extra: "field", + nested: { id: "value" }, + }, + }; + const result1 = await transformRequestBody(body, codexInstructions); + const firstKey = result1.prompt_cache_key; + // Should generate fallback cache key + expect(typeof firstKey).toBe("string"); + expect(firstKey).toMatch(/^cache_/); + + // Second transform of the same body should reuse the existing key + const result2 = await transformRequestBody(body, codexInstructions); + expect(result2.prompt_cache_key).toBe(firstKey); + }); - it('should handle very long content', async () => { - const longContent = 'a'.repeat(10000); + it("should handle very long content", async () => { + const longContent = "a".repeat(10000); const body: RequestBody = { - model: 'gpt-5', - input: [ - { type: 'message', role: 'user', content: longContent }, - ], + model: "gpt-5", + input: [{ type: "message", role: "user", content: longContent }], }; const result = await transformRequestBody(body, codexInstructions); expect(result.input![0].content).toBe(longContent); }); - it('should handle unicode content', async () => { - const unicodeContent = 'Hello 世界 🚀 emoji test'; + it("should handle unicode content", async () => { + const unicodeContent = "Hello 世界 🚀 emoji test"; const body: RequestBody = { - model: 'gpt-5', - input: [ - { type: 'message', role: 'user', content: unicodeContent }, - ], + model: "gpt-5", + input: [{ type: "message", role: "user", content: unicodeContent }], }; const result = await transformRequestBody(body, codexInstructions); expect(result.input![0].content).toBe(unicodeContent); }); }); -}); \ No newline at end of file +}); From 774bcfa8e3094fcd4746e2618d14211424ee623e Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:24:41 -0600 Subject: [PATCH 20/22] fixed minor type error --- lib/logger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/logger.ts b/lib/logger.ts index 011e72c..0207da6 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -123,7 +123,7 @@ function logToConsole( error?: unknown, ): void { const shouldLog = CONSOLE_LOGGING_ENABLED || level === "warn" || level === "error"; - if (IS_TEST_ENV && !shouldLog && level !== "error") { + if (IS_TEST_ENV && !shouldLog) { return; } const prefix = `[${PLUGIN_NAME}] ${message}`; From 4b574764931cbf24b85b71dd40742411503fbf27 Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:33:17 -0600 Subject: [PATCH 21/22] test: remove redundant env reset and header mock --- test/codex-fetcher.test.ts | 1 - test/logger.test.ts | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/test/codex-fetcher.test.ts b/test/codex-fetcher.test.ts index 71d7ea6..df0d2da 100644 --- a/test/codex-fetcher.test.ts +++ b/test/codex-fetcher.test.ts @@ -62,7 +62,6 @@ describe("createCodexFetcher", () => { refreshAndUpdateTokenMock.mockReset(); transformRequestForCodexMock.mockReset(); createCodexHeadersMock.mockReset(); - createCodexHeadersMock.mockImplementation(() => new Headers({ Authorization: 'Bearer token' })); handleErrorResponseMock.mockReset(); handleSuccessResponseMock.mockReset(); handleSuccessResponseMock.mockResolvedValue(new Response("handled", { status: 200 })); diff --git a/test/logger.test.ts b/test/logger.test.ts index a251c54..513e972 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; const fsMocks = { writeFileSync: vi.fn(), @@ -36,10 +36,6 @@ beforeEach(() => { warnSpy.mockClear(); }); -afterEach(() => { - Object.assign(process.env, originalEnv); -}); - describe('logger', () => { it('LOGGING_ENABLED reflects env state', async () => { process.env.ENABLE_PLUGIN_REQUEST_LOGGING = '1'; From c9b80f8b10a7cd02cd4b432a8a7a042f4e81cf1b Mon Sep 17 00:00:00 2001 From: Error Date: Wed, 19 Nov 2025 16:55:03 -0600 Subject: [PATCH 22/22] Reduce console logging to debug flag --- docs/configuration.md | 7 ++ lib/logger.ts | 159 +++++++++++++++++++++++++-- spec/logging-rotation-async-io.md | 30 +++++ test/logger.test.ts | 175 +++++++++++++++++++++--------- 4 files changed, 310 insertions(+), 61 deletions(-) create mode 100644 spec/logging-rotation-async-io.md diff --git a/docs/configuration.md b/docs/configuration.md index 0af730e..e0fa8e2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -368,6 +368,13 @@ Advanced plugin settings in `~/.opencode/openhax-codex-config.json`: } ``` +### Log file management + +Control local request/rolling log growth: +- `CODEX_LOG_MAX_BYTES` (default: 5_242_880) - rotate when the rolling log exceeds this many bytes. +- `CODEX_LOG_MAX_FILES` (default: 5) - number of rotated log files to retain (plus the active log). +- `CODEX_LOG_QUEUE_MAX` (default: 1000) - maximum buffered log entries before oldest entries are dropped. + ### CODEX_MODE **What it does:** diff --git a/lib/logger.ts b/lib/logger.ts index 0207da6..7d24e12 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,5 +1,5 @@ import type { OpencodeClient } from "@opencode-ai/sdk"; -import { writeFileSync, appendFileSync } from "node:fs"; +import { appendFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { PLUGIN_NAME } from "./constants.js"; import { ensureDirectory, getOpenCodePath } from "./utils/file-system-utils.js"; @@ -7,11 +7,15 @@ import { ensureDirectory, getOpenCodePath } from "./utils/file-system-utils.js"; export const LOGGING_ENABLED = process.env.ENABLE_PLUGIN_REQUEST_LOGGING === "1"; const DEBUG_FLAG_ENABLED = process.env.DEBUG_CODEX_PLUGIN === "1"; const DEBUG_ENABLED = DEBUG_FLAG_ENABLED || LOGGING_ENABLED; -const CONSOLE_LOGGING_ENABLED = LOGGING_ENABLED || DEBUG_FLAG_ENABLED; +const CONSOLE_LOGGING_ENABLED = DEBUG_FLAG_ENABLED; const LOG_DIR = getOpenCodePath("logs", "codex-plugin"); const ROLLING_LOG_FILE = join(LOG_DIR, "codex-plugin.log"); const IS_TEST_ENV = process.env.VITEST === "1" || process.env.NODE_ENV === "test"; +const LOG_ROTATION_MAX_BYTES = Math.max(1, getEnvNumber("CODEX_LOG_MAX_BYTES", 5 * 1024 * 1024)); +const LOG_ROTATION_MAX_FILES = Math.max(1, getEnvNumber("CODEX_LOG_MAX_FILES", 5)); +const LOG_QUEUE_MAX_LENGTH = Math.max(1, getEnvNumber("CODEX_LOG_QUEUE_MAX", 1000)); + type LogLevel = "debug" | "info" | "warn" | "error"; type LoggerOptions = { @@ -32,6 +36,14 @@ let loggerClient: OpencodeClient | undefined; let projectDirectory: string | undefined; let announcedState = false; +const writeQueue: string[] = []; +let flushInProgress = false; +let flushScheduled = false; +let overflowNotified = false; +let pendingFlush: Promise | undefined; +let currentLogSize = 0; +let sizeInitialized = false; + export function configureLogger(options: LoggerOptions = {}): void { if (options.client) { loggerClient = options.client; @@ -89,6 +101,13 @@ export function logError(message: string, data?: unknown): void { emit("error", message, normalizeExtra(data)); } +export async function flushRollingLogsForTest(): Promise { + scheduleFlush(); + if (pendingFlush) { + await pendingFlush; + } +} + function emit(level: LogLevel, message: string, extra?: Record): void { const sanitizedExtra = sanitizeExtra(extra); const entry: RollingLogEntry = { @@ -108,7 +127,7 @@ function emit(level: LogLevel, message: string, extra?: Record) }) .catch((error) => logToConsole("warn", "Failed to forward log entry", { - error: error instanceof Error ? error.message : String(error), + error: toErrorMessage(error), }), ); } @@ -162,24 +181,148 @@ function persistRequestStage(stage: string, payload: Record): s try { ensureLogDir(); const filename = join(LOG_DIR, `request-${payload.requestId}-${stage}.json`); - writeFileSync(filename, JSON.stringify(payload, null, 2), "utf8"); + void writeFile(filename, JSON.stringify(payload, null, 2), "utf8").catch((error) => { + logToConsole("warn", "Failed to persist request log", { + stage, + error: toErrorMessage(error), + }); + }); return filename; } catch (err) { - emit("warn", "Failed to persist request log", { + logToConsole("warn", "Failed to prepare request log", { stage, - error: err instanceof Error ? err.message : String(err), + error: toErrorMessage(err), }); return undefined; } } function appendRollingLog(entry: RollingLogEntry): void { + const line = `${JSON.stringify(entry)}\n`; + enqueueLogLine(line); +} + +function enqueueLogLine(line: string): void { + if (writeQueue.length >= LOG_QUEUE_MAX_LENGTH) { + writeQueue.shift(); + if (!overflowNotified) { + overflowNotified = true; + logToConsole("warn", "Rolling log queue overflow; dropping oldest entries", { + maxQueueLength: LOG_QUEUE_MAX_LENGTH, + }); + } + } + writeQueue.push(line); + scheduleFlush(); +} + +function scheduleFlush(): void { + if (flushScheduled || flushInProgress) { + return; + } + flushScheduled = true; + pendingFlush = Promise.resolve() + .then(flushQueue) + .catch((error) => + logToConsole("warn", "Failed to flush rolling logs", { + error: toErrorMessage(error), + }), + ); +} + +async function flushQueue(): Promise { + if (flushInProgress) return; + flushInProgress = true; + flushScheduled = false; + try { ensureLogDir(); - appendFileSync(ROLLING_LOG_FILE, `${JSON.stringify(entry)}\n`, "utf8"); + while (writeQueue.length) { + const chunk = writeQueue.join(""); + writeQueue.length = 0; + const chunkBytes = Buffer.byteLength(chunk, "utf8"); + await maybeRotate(chunkBytes); + await appendFile(ROLLING_LOG_FILE, chunk, "utf8"); + currentLogSize += chunkBytes; + } } catch (err) { logToConsole("warn", "Failed to write rolling log", { - error: err instanceof Error ? err.message : String(err), + error: toErrorMessage(err), }); + } finally { + flushInProgress = false; + if (writeQueue.length) { + scheduleFlush(); + } else { + overflowNotified = false; + } + } +} + +async function maybeRotate(incomingBytes: number): Promise { + await ensureLogSize(); + if (currentLogSize + incomingBytes <= LOG_ROTATION_MAX_BYTES) { + return; + } + await rotateLogs(); + currentLogSize = 0; +} + +async function ensureLogSize(): Promise { + if (sizeInitialized) return; + try { + const stats = await stat(ROLLING_LOG_FILE); + currentLogSize = stats.size; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + logToConsole("warn", "Failed to stat rolling log", { error: toErrorMessage(error) }); + } + currentLogSize = 0; + } finally { + sizeInitialized = true; + } +} + +async function rotateLogs(): Promise { + const oldest = `${ROLLING_LOG_FILE}.${LOG_ROTATION_MAX_FILES}`; + try { + await rm(oldest, { force: true }); + } catch { + /* ignore */ + } + for (let index = LOG_ROTATION_MAX_FILES - 1; index >= 1; index -= 1) { + const source = `${ROLLING_LOG_FILE}.${index}`; + const target = `${ROLLING_LOG_FILE}.${index + 1}`; + try { + await rename(source, target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } + try { + await rename(ROLLING_LOG_FILE, `${ROLLING_LOG_FILE}.1`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } +} + +function getEnvNumber(name: string, fallback: number): number { + const raw = process.env[name]; + const parsed = raw ? Number(raw) : Number.NaN; + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return fallback; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; } + return String(error); } diff --git a/spec/logging-rotation-async-io.md b/spec/logging-rotation-async-io.md new file mode 100644 index 0000000..bac2b88 --- /dev/null +++ b/spec/logging-rotation-async-io.md @@ -0,0 +1,30 @@ +# Logging rotation & async I/O spec + +## Context +- Rolling log currently uses `appendFileSync` and never rotates, so `codex-plugin.log` can grow without bound in long-running processes. +- Request stage files are persisted synchronously via `writeFileSync`, and rolling log writes occur on every emit, blocking the event loop. + +## Relevant files +- `lib/logger.ts`: append path setup and sync writes (`appendFileSync` in `appendRollingLog`, `writeFileSync` in `persistRequestStage`) — lines ~1-185. +- `lib/utils/file-system-utils.ts`: directory helpers (`ensureDirectory`, `safeWriteFile`) — lines ~1-77. +- `test/logger.test.ts`: expectations around sync writes/console behavior — lines ~1-113. +- `test/prompts-codex.test.ts`, `test/prompts-opencode-codex.test.ts`, `test/plugin-config.test.ts`: mock `appendFileSync` hooks that may need updates — see rg results. + +## Existing issues / PRs +- No open issues specifically about logging/rotation (checked `gh issue list`). +- Open PR #27 `feat/gpt-5.1-codex-max support with xhigh reasoning and persistent logging` on this branch; ensure changes stay compatible. + +## Definition of done +- Rolling log writes are asynchronous and buffered; synchronous hot-path blocking is removed. +- Log rotation enforced with configurable max size and retention of N files; old logs cleaned when limits hit. +- Write queue handles overflow gracefully (drops oldest or rate-limits) without crashing the process and surfaces a warning. +- Tests updated/added for new behavior; existing suites pass. +- Documentation/config defaults captured if new env/config options are introduced. + +## Requirements & approach sketch +- Introduce rotation settings (e.g., max bytes, max files) with reasonable defaults and env overrides. +- Implement a buffered async writer for the rolling log with sequential flushing to avoid contention and ensure ordering. +- On rotation trigger, rename current log with sequential suffix and prune files beyond retention. +- Define queue max length; on overflow, drop oldest buffered entries and emit a warning once per overflow window to avoid log storms. +- Keep request-stage JSON persistence working; consider leaving synchronous writes since they are occasional, but ensure they respect new directory management. +- Update tests/mocks to reflect async writer and rotation behavior. diff --git a/test/logger.test.ts b/test/logger.test.ts index 513e972..1121bd0 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -1,112 +1,181 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from "vitest"; const fsMocks = { - writeFileSync: vi.fn(), - appendFileSync: vi.fn(), + writeFile: vi.fn(), + appendFile: vi.fn(), mkdirSync: vi.fn(), existsSync: vi.fn(), + stat: vi.fn(), + rename: vi.fn(), + rm: vi.fn(), }; -vi.mock('node:fs', () => ({ - writeFileSync: fsMocks.writeFileSync, - appendFileSync: fsMocks.appendFileSync, - mkdirSync: fsMocks.mkdirSync, +vi.mock("node:fs", () => ({ existsSync: fsMocks.existsSync, + mkdirSync: fsMocks.mkdirSync, +})); + +vi.mock("node:fs/promises", () => ({ + __esModule: true, + writeFile: fsMocks.writeFile, + appendFile: fsMocks.appendFile, + stat: fsMocks.stat, + rename: fsMocks.rename, + rm: fsMocks.rm, })); -vi.mock('node:os', () => ({ +vi.mock("node:os", () => ({ __esModule: true, - homedir: () => '/mock-home', + homedir: () => "/mock-home", })); const originalEnv = { ...process.env }; -const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); -const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); +const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); +const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); beforeEach(() => { vi.resetModules(); Object.assign(process.env, originalEnv); delete process.env.ENABLE_PLUGIN_REQUEST_LOGGING; delete process.env.DEBUG_CODEX_PLUGIN; - fsMocks.writeFileSync.mockReset(); - fsMocks.appendFileSync.mockReset(); + delete process.env.CODEX_LOG_MAX_BYTES; + delete process.env.CODEX_LOG_MAX_FILES; + delete process.env.CODEX_LOG_QUEUE_MAX; + fsMocks.writeFile.mockReset(); + fsMocks.appendFile.mockReset(); fsMocks.mkdirSync.mockReset(); fsMocks.existsSync.mockReset(); + fsMocks.stat.mockReset(); + fsMocks.rename.mockReset(); + fsMocks.rm.mockReset(); + fsMocks.appendFile.mockResolvedValue(undefined); + fsMocks.writeFile.mockResolvedValue(undefined); + fsMocks.stat.mockRejectedValue(Object.assign(new Error("no file"), { code: "ENOENT" })); logSpy.mockClear(); warnSpy.mockClear(); + errorSpy.mockClear(); }); -describe('logger', () => { - it('LOGGING_ENABLED reflects env state', async () => { - process.env.ENABLE_PLUGIN_REQUEST_LOGGING = '1'; - const { LOGGING_ENABLED } = await import('../lib/logger.js'); +describe("logger", () => { + it("LOGGING_ENABLED reflects env state", async () => { + process.env.ENABLE_PLUGIN_REQUEST_LOGGING = "1"; + const { LOGGING_ENABLED } = await import("../lib/logger.js"); expect(LOGGING_ENABLED).toBe(true); }); - it('logRequest writes stage file and rolling log by default', async () => { + it("logRequest writes stage file and rolling log by default", async () => { fsMocks.existsSync.mockReturnValue(false); - const { logRequest } = await import('../lib/logger.js'); + const { logRequest, flushRollingLogsForTest } = await import("../lib/logger.js"); - logRequest('stage-one', { foo: 'bar' }); + logRequest("stage-one", { foo: "bar" }); + await flushRollingLogsForTest(); - expect(fsMocks.mkdirSync).toHaveBeenCalledWith('/mock-home/.opencode/logs/codex-plugin', { recursive: true }); - const [requestPath, payload, encoding] = fsMocks.writeFileSync.mock.calls[0]; - expect(requestPath).toBe('/mock-home/.opencode/logs/codex-plugin/request-1-stage-one.json'); - expect(encoding).toBe('utf8'); + expect(fsMocks.mkdirSync).toHaveBeenCalledWith("/mock-home/.opencode/logs/codex-plugin", { + recursive: true, + }); + const [requestPath, payload, encoding] = fsMocks.writeFile.mock.calls[0]; + expect(requestPath).toBe("/mock-home/.opencode/logs/codex-plugin/request-1-stage-one.json"); + expect(encoding).toBe("utf8"); const parsedPayload = JSON.parse(payload as string); - expect(parsedPayload.stage).toBe('stage-one'); - expect(parsedPayload.foo).toBe('bar'); + expect(parsedPayload.stage).toBe("stage-one"); + expect(parsedPayload.foo).toBe("bar"); - const [logPath, logLine, logEncoding] = fsMocks.appendFileSync.mock.calls[0]; - expect(logPath).toBe('/mock-home/.opencode/logs/codex-plugin/codex-plugin.log'); - expect(logEncoding).toBe('utf8'); + const [logPath, logLine, logEncoding] = fsMocks.appendFile.mock.calls[0]; + expect(logPath).toBe("/mock-home/.opencode/logs/codex-plugin/codex-plugin.log"); + expect(logEncoding).toBe("utf8"); expect(logLine as string).toContain('"stage":"stage-one"'); expect(logSpy).not.toHaveBeenCalled(); }); - it('logDebug appends to rolling log without printing to console by default', async () => { + it("logDebug appends to rolling log without printing to console by default", async () => { fsMocks.existsSync.mockReturnValue(true); - const { logDebug } = await import('../lib/logger.js'); + const { logDebug, flushRollingLogsForTest } = await import("../lib/logger.js"); - logDebug('debug-message', { detail: 'info' }); + logDebug("debug-message", { detail: "info" }); + await flushRollingLogsForTest(); - expect(fsMocks.appendFileSync).toHaveBeenCalledTimes(1); + expect(fsMocks.appendFile).toHaveBeenCalledTimes(1); expect(logSpy).not.toHaveBeenCalled(); }); - it('logWarn emits to console even without env overrides', async () => { + it("logWarn emits to console even without env overrides", async () => { fsMocks.existsSync.mockReturnValue(true); - const { logWarn } = await import('../lib/logger.js'); + const { logWarn, flushRollingLogsForTest } = await import("../lib/logger.js"); - logWarn('warning'); + logWarn("warning"); + await flushRollingLogsForTest(); - expect(warnSpy).toHaveBeenCalledWith('[openai-codex-plugin] warning'); + expect(warnSpy).toHaveBeenCalledWith("[openai-codex-plugin] warning"); }); - it('logInfo only mirrors to console when logging env is enabled', async () => { + it("logInfo does not mirror to console unless debug flag is set", async () => { fsMocks.existsSync.mockReturnValue(true); - const { logInfo } = await import('../lib/logger.js'); - logInfo('info-message'); + const { logInfo, flushRollingLogsForTest } = await import("../lib/logger.js"); + logInfo("info-message"); + await flushRollingLogsForTest(); expect(logSpy).not.toHaveBeenCalled(); - process.env.ENABLE_PLUGIN_REQUEST_LOGGING = '1'; - await vi.resetModules(); + process.env.ENABLE_PLUGIN_REQUEST_LOGGING = "1"; + vi.resetModules(); fsMocks.existsSync.mockReturnValue(true); - const { logInfo: envLogInfo } = await import('../lib/logger.js'); - envLogInfo('info-message'); - expect(logSpy).toHaveBeenCalledWith('[openai-codex-plugin] info-message'); + const { logInfo: envLogInfo, flushRollingLogsForTest: flushEnabled } = await import("../lib/logger.js"); + envLogInfo("info-message"); + await flushEnabled(); + expect(logSpy).not.toHaveBeenCalled(); }); - it('persist failures log warnings and append entries', async () => { + it("persist failures log warnings and still append entries", async () => { fsMocks.existsSync.mockReturnValue(true); - fsMocks.writeFileSync.mockImplementation(() => { - throw new Error('boom'); - }); - const { logRequest } = await import('../lib/logger.js'); + fsMocks.writeFile.mockRejectedValue(new Error("boom")); + const { logRequest, flushRollingLogsForTest } = await import("../lib/logger.js"); - logRequest('stage-two', { foo: 'bar' }); + logRequest("stage-two", { foo: "bar" }); + await flushRollingLogsForTest(); - expect(warnSpy).toHaveBeenCalledWith('[openai-codex-plugin] Failed to persist request log {"stage":"stage-two","error":"boom"}'); - expect(fsMocks.appendFileSync).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '[openai-codex-plugin] Failed to persist request log {"stage":"stage-two","error":"boom"}', + ); + expect(fsMocks.appendFile).toHaveBeenCalled(); + }); + + it("rotates logs when size exceeds limit", async () => { + process.env.CODEX_LOG_MAX_BYTES = "10"; + process.env.CODEX_LOG_MAX_FILES = "2"; + fsMocks.existsSync.mockReturnValue(true); + fsMocks.stat.mockResolvedValue({ size: 9 }); + const { logDebug, flushRollingLogsForTest } = await import("../lib/logger.js"); + + logDebug("trigger-rotation"); + await flushRollingLogsForTest(); + + expect(fsMocks.rm).toHaveBeenCalledWith("/mock-home/.opencode/logs/codex-plugin/codex-plugin.log.2", { + force: true, + }); + expect(fsMocks.rename).toHaveBeenCalledWith( + "/mock-home/.opencode/logs/codex-plugin/codex-plugin.log", + "/mock-home/.opencode/logs/codex-plugin/codex-plugin.log.1", + ); + expect(fsMocks.appendFile).toHaveBeenCalled(); + }); + + it("drops oldest buffered logs when queue overflows", async () => { + process.env.CODEX_LOG_QUEUE_MAX = "2"; + fsMocks.existsSync.mockReturnValue(true); + const { logDebug, flushRollingLogsForTest } = await import("../lib/logger.js"); + + logDebug("first"); + logDebug("second"); + logDebug("third"); + await flushRollingLogsForTest(); + + expect(fsMocks.appendFile).toHaveBeenCalledTimes(1); + const appended = fsMocks.appendFile.mock.calls[0][1] as string; + expect(appended).toContain('"message":"second"'); + expect(appended).toContain('"message":"third"'); + expect(appended).not.toContain('"message":"first"'); + expect(warnSpy).toHaveBeenCalledWith( + '[openai-codex-plugin] Rolling log queue overflow; dropping oldest entries {"maxQueueLength":2}', + ); }); });