diff --git a/docs/configuration.md b/docs/configuration.md index e0fa8e2..ca0c9e5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -459,7 +459,7 @@ DEBUG_CODEX_PLUGIN=1 opencode run "test" --model=openai/your-model-name Look for: ``` -[openai-codex-plugin] Model config lookup: "your-model-name" → normalized to "gpt-5-codex" for API { +[openhax/codex] Model config lookup: "your-model-name" → normalized to "gpt-5-codex" for API { hasModelSpecificConfig: true, resolvedConfig: { ... } } diff --git a/docs/development/ARCHITECTURE.md b/docs/development/ARCHITECTURE.md index 630def6..99f6501 100644 --- a/docs/development/ARCHITECTURE.md +++ b/docs/development/ARCHITECTURE.md @@ -193,13 +193,13 @@ The plugin logs ID filtering for debugging: ```typescript // Before filtering -console.log(`[openai-codex-plugin] Filtering ${originalIds.length} message IDs from input:`, originalIds); +console.log(`[openhax/codex] Filtering ${originalIds.length} message IDs from input:`, originalIds); // After filtering -console.log(`[openai-codex-plugin] Successfully removed all ${originalIds.length} message IDs`); +console.log(`[openhax/codex] Successfully removed all ${originalIds.length} message IDs`); // Or warning if IDs remain -console.warn(`[openai-codex-plugin] WARNING: ${remainingIds.length} IDs still present after filtering:`, remainingIds); +console.warn(`[openhax/codex] WARNING: ${remainingIds.length} IDs still present after filtering:`, remainingIds); ``` **Source**: `lib/request/request-transformer.ts:287-301` diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index 18b4a5c..4eaf45c 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -375,8 +375,8 @@ DEBUG_CODEX_PLUGIN=1 opencode run "test" --model=openai/gpt-5-codex-low #### Case 1: Custom Model with Config ``` -[openai-codex-plugin] Debug logging ENABLED -[openai-codex-plugin] Model config lookup: "gpt-5-codex-low" → normalized to "gpt-5-codex" for API { +[openhax/codex] Debug logging ENABLED +[openhax/codex] Model config lookup: "gpt-5-codex-low" → normalized to "gpt-5-codex" for API { hasModelSpecificConfig: true, resolvedConfig: { reasoningEffort: 'low', @@ -385,7 +385,7 @@ DEBUG_CODEX_PLUGIN=1 opencode run "test" --model=openai/gpt-5-codex-low include: ['reasoning.encrypted_content'] } } -[openai-codex-plugin] Filtering 0 message IDs from input: [] +[openhax/codex] Filtering 0 message IDs from input: [] ``` ✅ **Verify:** `hasModelSpecificConfig: true` confirms per-model options found @@ -399,8 +399,8 @@ DEBUG_CODEX_PLUGIN=1 opencode run "test" --model=openai/gpt-5-codex ``` ``` -[openai-codex-plugin] Debug logging ENABLED -[openai-codex-plugin] Model config lookup: "gpt-5-codex" → normalized to "gpt-5-codex" for API { +[openhax/codex] Debug logging ENABLED +[openhax/codex] Model config lookup: "gpt-5-codex" → normalized to "gpt-5-codex" for API { hasModelSpecificConfig: false, resolvedConfig: { reasoningEffort: 'medium', @@ -409,7 +409,7 @@ DEBUG_CODEX_PLUGIN=1 opencode run "test" --model=openai/gpt-5-codex include: ['reasoning.encrypted_content'] } } -[openai-codex-plugin] Filtering 0 message IDs from input: [] +[openhax/codex] Filtering 0 message IDs from input: [] ``` ✅ **Verify:** `hasModelSpecificConfig: false` confirms using global options @@ -419,8 +419,8 @@ DEBUG_CODEX_PLUGIN=1 opencode run "test" --model=openai/gpt-5-codex #### Case 3: Multi-Turn with ID Filtering ``` -[openai-codex-plugin] Filtering 3 message IDs from input: ['msg_abc123', 'rs_xyz789', 'msg_def456'] -[openai-codex-plugin] Successfully removed all 3 message IDs +[openhax/codex] Filtering 3 message IDs from input: ['msg_abc123', 'rs_xyz789', 'msg_def456'] +[openhax/codex] Successfully removed all 3 message IDs ``` ✅ **Verify:** All IDs removed, no warnings @@ -430,7 +430,7 @@ DEBUG_CODEX_PLUGIN=1 opencode run "test" --model=openai/gpt-5-codex #### Case 4: Warning if IDs Leak (Should Never Happen) ``` -[openai-codex-plugin] WARNING: 1 IDs still present after filtering: ['msg_abc123'] +[openhax/codex] WARNING: 1 IDs still present after filtering: ['msg_abc123'] ``` ❌ **This would indicate a bug** - should never appear diff --git a/lib/constants.ts b/lib/constants.ts index 03a2c44..bdae8c6 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -4,7 +4,7 @@ */ /** Plugin identifier for logging and error messages */ -export const PLUGIN_NAME = "openai-codex-plugin"; +export const PLUGIN_NAME = "openhax/codex"; /** Base URL for ChatGPT backend API */ export const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; diff --git a/lib/logger.ts b/lib/logger.ts index 7d24e12..e961552 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -142,7 +142,7 @@ function logToConsole( error?: unknown, ): void { const shouldLog = CONSOLE_LOGGING_ENABLED || level === "warn" || level === "error"; - if (IS_TEST_ENV && !shouldLog) { + if (!shouldLog) { return; } const prefix = `[${PLUGIN_NAME}] ${message}`; diff --git a/lib/prompts/opencode-codex.ts b/lib/prompts/opencode-codex.ts index 1c44721..44a0b68 100644 --- a/lib/prompts/opencode-codex.ts +++ b/lib/prompts/opencode-codex.ts @@ -13,11 +13,14 @@ import { logError, logWarn, logInfo } from "../logger.js"; import { CACHE_FILES, CACHE_TTL_MS, LEGACY_CACHE_FILES, PLUGIN_PREFIX } from "../utils/cache-config.js"; import { getOpenCodePath } from "../utils/file-system-utils.js"; -const OPENCODE_CODEX_URL = - "https://raw.githubusercontent.com/sst/opencode/main/packages/opencode/src/session/prompt/codex.txt"; +const OPENCODE_CODEX_URLS = [ + "https://raw.githubusercontent.com/sst/opencode/dev/packages/opencode/src/session/prompt/codex.txt", + "https://raw.githubusercontent.com/sst/opencode/main/packages/opencode/src/session/prompt/codex.txt", +]; interface OpenCodeCacheMeta { etag: string; + sourceUrl?: string; lastFetch?: string; // Legacy field for backwards compatibility lastChecked: number; // Timestamp for rate limit protection url?: string; // Track source URL for validation @@ -142,88 +145,86 @@ export async function getOpenCodeCodexPrompt(): Promise { return cachedContent; } - // Fetch from GitHub with conditional request - const headers: Record = {}; - if (cachedMeta?.etag) { - headers["If-None-Match"] = cachedMeta.etag; - } - - try { - const response = await fetch(OPENCODE_CODEX_URL, { headers }); - - // 304 Not Modified - cache is still valid - if (response.status === 304 && cachedContent) { - // Store in session cache - openCodePromptCache.set("main", { data: cachedContent, etag: cachedMeta?.etag || undefined }); - return cachedContent; + // Fetch from GitHub with conditional requests and fallbacks + let lastError: Error | undefined; + for (const url of OPENCODE_CODEX_URLS) { + const headers: Record = {}; + if (cachedMeta?.etag && (!cachedMeta.sourceUrl || cachedMeta.sourceUrl === url)) { + headers["If-None-Match"] = cachedMeta.etag; } - // 200 OK - new content available - if (response.ok) { - const content = await response.text(); - const etag = response.headers.get("etag") || ""; - - // Save to cache with timestamp and plugin identifier - await writeFile(cacheFilePath, content, "utf-8"); - await writeFile( - cacheMetaPath, - JSON.stringify( - { - etag, - lastFetch: new Date().toISOString(), // Keep for backwards compat - lastChecked: Date.now(), - url: OPENCODE_CODEX_URL, // Track source URL for validation - } satisfies OpenCodeCacheMeta, - null, - 2, - ), - "utf-8", - ); - - // Store in session cache - openCodePromptCache.set("main", { data: content, etag }); - - return content; - } - - // Fallback to cache if available - if (cachedContent) { - logWarn("Using cached OpenCode prompt due to fetch failure", { - status: response.status, - cacheAge: cachedMeta ? Date.now() - cachedMeta.lastChecked : "unknown", - }); - openCodePromptCache.set("main", { data: cachedContent, etag: cachedMeta?.etag || undefined }); - return cachedContent; - } - - throw new Error(`Failed to fetch OpenCode codex.txt: ${response.status}`); - } catch (error) { - const err = error as Error; - logError("Failed to fetch OpenCode codex.txt from GitHub", { error: err.message }); - - // Network error - fallback to cache - if (cachedContent) { - logWarn("Network error detected, using cached OpenCode prompt", { - error: err.message, - cacheAge: cachedMeta ? Date.now() - cachedMeta.lastChecked : "unknown", - }); - - // Store in session cache even for fallback - openCodePromptCache.set("main", { data: cachedContent, etag: cachedMeta?.etag || undefined }); - return cachedContent; + try { + const response = await fetch(url, { headers }); + + // 304 Not Modified - cache is still valid + if (response.status === 304 && cachedContent) { + const updatedMeta: OpenCodeCacheMeta = { + etag: cachedMeta?.etag || "", + sourceUrl: cachedMeta?.sourceUrl || url, + lastFetch: cachedMeta?.lastFetch, + lastChecked: Date.now(), + url: cachedMeta?.url, + }; + await writeFile(cacheMetaPath, JSON.stringify(updatedMeta, null, 2), "utf-8"); + + openCodePromptCache.set("main", { data: cachedContent, etag: updatedMeta.etag || undefined }); + return cachedContent; + } + + // 200 OK - new content available + if (response.ok) { + const content = await response.text(); + const etag = response.headers.get("etag") || ""; + + await writeFile(cacheFilePath, content, "utf-8"); + await writeFile( + cacheMetaPath, + JSON.stringify( + { + etag, + sourceUrl: url, + lastFetch: new Date().toISOString(), // Keep for backwards compat + lastChecked: Date.now(), + } satisfies OpenCodeCacheMeta, + null, + 2, + ), + "utf-8", + ); + + openCodePromptCache.set("main", { data: content, etag }); + + return content; + } + + lastError = new Error(`HTTP ${response.status} from ${url}`); + } catch (error) { + const err = error as Error; + lastError = new Error(`Failed to fetch ${url}: ${err.message}`); } + } - // Provide helpful error message for cache conflicts - if (err.message.includes("404") || err.message.includes("ENOENT")) { - throw new Error( - `Failed to fetch OpenCode prompt and no valid cache available. ` + - `This may happen when switching between different Codex plugins. ` + - `Try clearing the cache with: rm -rf ~/.opencode/cache/opencode* && rm -rf ~/.opencode/cache/codex*`, - ); - } + if (lastError) { + logError("Failed to fetch OpenCode codex.txt from GitHub", { error: lastError.message }); + } - throw new Error(`Failed to fetch OpenCode codex.txt and no cache available: ${err.message}`); + if (cachedContent) { + const updatedMeta: OpenCodeCacheMeta = { + etag: cachedMeta?.etag || "", + sourceUrl: cachedMeta?.sourceUrl, + lastFetch: cachedMeta?.lastFetch, + lastChecked: Date.now(), + url: cachedMeta?.url, + }; + await writeFile(cacheMetaPath, JSON.stringify(updatedMeta, null, 2), "utf-8"); + + openCodePromptCache.set("main", { data: cachedContent, etag: updatedMeta.etag || undefined }); + return cachedContent; } + + throw new Error( + `Failed to fetch OpenCode codex.txt and no cache available: ${lastError?.message || "unknown error"}`, + ); } /** diff --git a/lib/request/response-handler.ts b/lib/request/response-handler.ts index f1cccbb..7a29989 100644 --- a/lib/request/response-handler.ts +++ b/lib/request/response-handler.ts @@ -1,3 +1,4 @@ +import { PLUGIN_NAME } from "../constants.js"; import { LOGGING_ENABLED, logError, logRequest } from "../logger.js"; import type { SSEEventData } from "../types.js"; @@ -35,7 +36,7 @@ function parseSseStream(sseText: string): unknown | null { */ export async function convertSseToJson(response: Response, headers: Headers): Promise { if (!response.body) { - throw new Error("[openai-codex-plugin] Response has no body"); + throw new Error(`${PLUGIN_NAME} Response has no body`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); diff --git a/spec/handle-missing-codex-prompt-warming.md b/spec/handle-missing-codex-prompt-warming.md new file mode 100644 index 0000000..cb6132a --- /dev/null +++ b/spec/handle-missing-codex-prompt-warming.md @@ -0,0 +1,31 @@ +# Handle missing codex prompt warming + +## Scope +- Review uncommitted changes on branch `chore/handle-missing-codex-prompt-warming` for plugin log prefix and fallback prompt fetch handling. + +## Relevant files & line notes +- `lib/constants.ts`:6-8 rename `PLUGIN_NAME` to `openhax/codex` for logging identity. +- `lib/logger.ts`:144-158 log gating simplified to always mirror warn/error/info when enabled; removes test-env suppression. +- `lib/prompts/opencode-codex.ts`:15-131 adds dev+main fallback URLs, stores `sourceUrl`, handles 304/etag per-source, logs last error, caches when available. +- `lib/request/response-handler.ts`:36-79 updates empty-body error prefix to new plugin name. +- Tests updated for new prefixes and caching behavior: `test/auth.test.ts`, `test/constants.test.ts`, `test/logger.test.ts`, `test/prompts-codex.test.ts`, `test/prompts-opencode-codex.test.ts` (new legacy URL fallback test). +- Docs updated to reflect new logging prefix: `docs/configuration.md`, `docs/development/ARCHITECTURE.md`, `docs/development/TESTING.md`. + +## Existing issues / PRs +- No linked issues or PRs referenced in the changes. + +## Requirements +- Ensure logging prefix consistently uses `openhax/codex` across code, tests, docs. +- OpenCode prompt fetcher should fall back to main branch when dev URL fails, preserving cache metadata including source URL. +- Maintain ETag-based caching and cache-hit/miss metrics with session/file caches. +- Tests should cover prefix changes and new fallback path. + +## Definition of done +- All modified files aligned on new plugin identifier. +- OpenCode codex prompt fetch resilient when dev URL missing; cache metadata persists `sourceUrl` and uses correct conditional requests. +- Unit tests updated/passing; docs reflect logging prefix. +- Branch ready with meaningful commit(s) and PR targeted to staging. + +## Notes +- Untracked spec files present (`spec/opencode-prompt-cache-404.md`, `spec/plugin-name-rename.md`); keep intact. +- Build/test commands: `npm test`, `npm run build`, `npm run typecheck` per AGENTS.md. diff --git a/spec/opencode-prompt-cache-404.md b/spec/opencode-prompt-cache-404.md new file mode 100644 index 0000000..413a065 --- /dev/null +++ b/spec/opencode-prompt-cache-404.md @@ -0,0 +1,25 @@ +# OpenCode Prompt Cache 404 + +## Context +- Timestamped warnings during startup show `getOpenCodeCodexPrompt` failing to seed cache due to 404 on codex.txt (logs at 2025-11-19, default config path missing). +- Current fetch URL targets `sst/opencode` on the `main` branch, which no longer hosts `packages/opencode/src/session/prompt/codex.txt`. + +## Existing Issues / PRs +- No related issues/PRs reviewed yet; check backlog if needed. + +## Code Files & References +- lib/prompts/opencode-codex.ts:15 – `OPENCODE_CODEX_URL` points to raw GitHub main branch and returns 404. +- lib/cache/cache-warming.ts:41-99 – startup warming logs errors when `getOpenCodeCodexPrompt` fails. +- lib/utils/file-system-utils.ts:15-23 – cache path under `~/.opencode/cache` used for prompt storage. +- test/prompts-opencode-codex.test.ts:82-297 – coverage for caching, TTL, and fetch fallback behavior. + +## Definition of Done +1. Update OpenCode prompt fetch logic to use a valid source and avoid 404s. +2. Preserve caching semantics (session + disk + TTL) and existing metrics behavior. +3. Ensure cache warming no longer logs repeated OpenCode fetch errors when network is available. +4. Tests cover the new fetch path/fallback path and continue to pass. + +## Requirements +- Add a resilient fetch strategy (e.g., prefer current branch/file path with fallback to legacy path) without breaking existing interfaces. +- Keep cache directory/filenames unchanged to avoid disrupting existing users. +- Maintain log levels (warn on failures) but succeed when a fallback fetch works. diff --git a/spec/plugin-name-rename.md b/spec/plugin-name-rename.md new file mode 100644 index 0000000..55e0656 --- /dev/null +++ b/spec/plugin-name-rename.md @@ -0,0 +1,18 @@ +# Plugin name rename to npm package name + +## Context +- Update plugin/service identifier to use the npm package name `openhax/codex`. + +## Relevant code +- lib/constants.ts:7 exports `PLUGIN_NAME` that is used for logging. +- test/constants.test.ts:18-21 asserts the current plugin identity string. + +## Tasks / Plan +1. Change `PLUGIN_NAME` to `openhax/codex` in `lib/constants.ts`. +2. Update tests and any string expectations to the new identifier. +3. Keep docs/examples consistent if they explicitly show the service name. + +## Definition of done +- Plugin logs use `openhax/codex` as the service name. +- Tests updated to match the new identifier and pass locally if run. +- No references to the legacy identifier remain in code/tests relevant to logging. diff --git a/test/auth.test.ts b/test/auth.test.ts index c757ae3..a118f30 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -205,7 +205,7 @@ describe("Auth Module", () => { const result = await exchangeAuthorizationCode("code", "verifier"); expect(result).toEqual({ type: "failed" }); expect(console.error).toHaveBeenCalledWith( - '[openai-codex-plugin] Authorization code exchange failed {"status":400,"body":"bad request"}', + '[openhax/codex] Authorization code exchange failed {"status":400,"body":"bad request"}', "", ); }); @@ -219,7 +219,7 @@ describe("Auth Module", () => { fetchMock.mockResolvedValueOnce(badRes); await exchangeAuthorizationCode("code", "verifier"); expect(console.error).toHaveBeenCalledWith( - '[openai-codex-plugin] Authorization code exchange failed {"status":500,"body":""}', + '[openhax/codex] Authorization code exchange failed {"status":500,"body":""}', "", ); }); @@ -232,7 +232,7 @@ describe("Auth Module", () => { const result = await exchangeAuthorizationCode("code", "verifier"); expect(result).toEqual({ type: "failed" }); expect(console.error).toHaveBeenCalledWith( - '[openai-codex-plugin] Token response missing fields {"access_token":"only-access"}', + '[openhax/codex] Token response missing fields {"access_token":"only-access"}', "", ); }); @@ -274,7 +274,7 @@ describe("Auth Module", () => { const result = await refreshAccessToken("refresh-token"); expect(result).toEqual({ type: "failed" }); expect(console.error).toHaveBeenCalledWith( - '[openai-codex-plugin] Token refresh failed {"status":401,"body":"denied"}', + '[openhax/codex] Token refresh failed {"status":401,"body":"denied"}', "", ); }); @@ -284,7 +284,7 @@ describe("Auth Module", () => { const result = await refreshAccessToken("refresh-token"); expect(result).toEqual({ type: "failed" }); expect(console.error).toHaveBeenCalledWith( - '[openai-codex-plugin] Token refresh error {"error":"network down"}', + '[openhax/codex] Token refresh error {"error":"network down"}', "", ); }); @@ -298,7 +298,7 @@ describe("Auth Module", () => { fetchMock.mockResolvedValueOnce(badRes); await refreshAccessToken("refresh-token"); expect(console.error).toHaveBeenCalledWith( - '[openai-codex-plugin] Token refresh failed {"status":403,"body":""}', + '[openhax/codex] Token refresh failed {"status":403,"body":""}', "", ); }); @@ -310,7 +310,7 @@ describe("Auth Module", () => { const result = await refreshAccessToken("refresh-token"); expect(result).toEqual({ type: "failed" }); expect(console.error).toHaveBeenCalledWith( - '[openai-codex-plugin] Token refresh response missing fields {"access_token":"only"}', + '[openhax/codex] Token refresh response missing fields {"access_token":"only"}', "", ); }); diff --git a/test/constants.test.ts b/test/constants.test.ts index 19c21a7..0eefa4f 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -16,7 +16,7 @@ import { describe("General constants", () => { it("exposes the codex plugin identity", () => { - expect(PLUGIN_NAME).toBe("openai-codex-plugin"); + expect(PLUGIN_NAME).toBe("openhax/codex"); expect(PROVIDER_ID).toBe("openai"); }); diff --git a/test/logger.test.ts b/test/logger.test.ts index 1121bd0..3bd62ad 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -106,7 +106,7 @@ describe("logger", () => { logWarn("warning"); await flushRollingLogsForTest(); - expect(warnSpy).toHaveBeenCalledWith("[openai-codex-plugin] warning"); + expect(warnSpy).toHaveBeenCalledWith("[openhax/codex] warning"); }); it("logInfo does not mirror to console unless debug flag is set", async () => { @@ -134,7 +134,7 @@ describe("logger", () => { await flushRollingLogsForTest(); expect(warnSpy).toHaveBeenCalledWith( - '[openai-codex-plugin] Failed to persist request log {"stage":"stage-two","error":"boom"}', + '[openhax/codex] Failed to persist request log {"stage":"stage-two","error":"boom"}', ); expect(fsMocks.appendFile).toHaveBeenCalled(); }); @@ -175,7 +175,7 @@ describe("logger", () => { 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}', + '[openhax/codex] Rolling log queue overflow; dropping oldest entries {"maxQueueLength":2}', ); }); }); diff --git a/test/prompts-codex.test.ts b/test/prompts-codex.test.ts index c20c3e9..fb39e0a 100644 --- a/test/prompts-codex.test.ts +++ b/test/prompts-codex.test.ts @@ -49,7 +49,6 @@ describe("Codex Instructions Fetcher", () => { codexInstructionsCache.clear(); }); - afterEach(() => { // Cleanup global fetch if needed delete (global as any).fetch; @@ -137,11 +136,11 @@ describe("Codex Instructions Fetcher", () => { expect(result).toBe("still-good"); expect(consoleError).toHaveBeenCalledWith( - '[openai-codex-plugin] Failed to fetch instructions from GitHub {"error":"HTTP 500"}', + '[openhax/codex] Failed to fetch instructions from GitHub {"error":"HTTP 500"}', "", ); expect(consoleError).toHaveBeenCalledWith( - "[openai-codex-plugin] Using cached instructions due to fetch failure", + "[openhax/codex] Using cached instructions due to fetch failure", "", ); consoleError.mockRestore(); @@ -244,13 +243,10 @@ describe("Codex Instructions Fetcher", () => { expect(typeof result).toBe("string"); expect(consoleError).toHaveBeenCalledWith( - '[openai-codex-plugin] Failed to fetch instructions from GitHub {"error":"HTTP 500"}', - "", - ); - expect(consoleError).toHaveBeenCalledWith( - "[openai-codex-plugin] Falling back to bundled instructions", + '[openhax/codex] Failed to fetch instructions from GitHub {"error":"HTTP 500"}', "", ); + expect(consoleError).toHaveBeenCalledWith("[openhax/codex] Falling back to bundled instructions", ""); const readPaths = readFileSync.mock.calls.map((call) => call[0] as string); const fallbackPath = readPaths.find( diff --git a/test/prompts-opencode-codex.test.ts b/test/prompts-opencode-codex.test.ts index 16c89c5..7b243b7 100644 --- a/test/prompts-opencode-codex.test.ts +++ b/test/prompts-opencode-codex.test.ts @@ -246,6 +246,30 @@ describe("OpenCode Codex Prompt Fetcher", () => { expect(result).toBe(cachedContent); }); + it("falls back to legacy URL when primary returns 404", async () => { + openCodePromptCache.get = vi.fn().mockReturnValue(undefined); + readFileMock.mockRejectedValue(new Error("No cache files")); + + fetchMock + .mockResolvedValueOnce(new Response("Missing", { status: 404 })) + .mockResolvedValueOnce( + new Response("legacy-content", { status: 200, headers: { etag: '"legacy-etag"' } }), + ); + + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); + const result = await getOpenCodeCodexPrompt(); + + expect(result).toBe("legacy-content"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][0]).toContain("/dev/"); + expect(fetchMock.mock.calls[1][0]).toContain("/main/"); + const metaWrite = writeFileMock.mock.calls.find((call) => call[0] === cacheMetaFile); + const metaPayload = metaWrite?.[1]; + const metaObject = typeof metaPayload === "string" ? JSON.parse(metaPayload) : metaPayload; + expect(metaObject?.etag).toBe('"legacy-etag"'); + expect(metaObject?.sourceUrl).toContain("/main/"); + }); + it("creates cache directory when it does not exist", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); readFileMock.mockRejectedValue(new Error("No cache files"));