diff --git a/lib/prompts/opencode-codex.ts b/lib/prompts/opencode-codex.ts index a66bb0f..1c44721 100644 --- a/lib/prompts/opencode-codex.ts +++ b/lib/prompts/opencode-codex.ts @@ -3,13 +3,14 @@ * * Fetches and caches codex.txt system prompt from OpenCode's GitHub repository. * Uses ETag-based caching to efficiently track updates. + * Handles cache conflicts when switching between different Codex plugins. */ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile, rename } from "node:fs/promises"; import { recordCacheHit, recordCacheMiss } from "../cache/cache-metrics.js"; import { openCodePromptCache } from "../cache/session-cache.js"; -import { logError } from "../logger.js"; -import { CACHE_FILES, CACHE_TTL_MS } from "../utils/cache-config.js"; +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 = @@ -19,10 +20,69 @@ interface OpenCodeCacheMeta { etag: string; lastFetch?: string; // Legacy field for backwards compatibility lastChecked: number; // Timestamp for rate limit protection + url?: string; // Track source URL for validation } /** - * Fetch OpenCode's codex.txt prompt with ETag-based caching + * Check if legacy cache files exist and migrate them + * @param cacheDir - Cache directory path + */ +async function migrateLegacyCache(cacheDir: string): Promise { + const legacyCachePath = getOpenCodePath("cache", LEGACY_CACHE_FILES.OPENCODE_CODEX); + const legacyMetaPath = getOpenCodePath("cache", LEGACY_CACHE_FILES.OPENCODE_CODEX_META); + + try { + // Check if legacy files exist + const legacyContent = await readFile(legacyCachePath, "utf-8"); + const legacyMeta = await readFile(legacyMetaPath, "utf-8"); + + // Legacy files found, migrate to our plugin-specific files + logWarn("Detected cache files from different plugin. Migrating to @openhax/codex cache...", { + legacyFiles: [LEGACY_CACHE_FILES.OPENCODE_CODEX, LEGACY_CACHE_FILES.OPENCODE_CODEX_META], + }); + + const newCachePath = getOpenCodePath("cache", CACHE_FILES.OPENCODE_CODEX); + const newMetaPath = getOpenCodePath("cache", CACHE_FILES.OPENCODE_CODEX_META); + + // Copy to new locations + await writeFile(newCachePath, legacyContent, "utf-8"); + await writeFile(newMetaPath, legacyMeta, "utf-8"); + + // Remove legacy files to prevent future conflicts + await rename(legacyCachePath, `${legacyCachePath}.backup.${Date.now()}`); + await rename(legacyMetaPath, `${legacyMetaPath}.backup.${Date.now()}`); + + logInfo("Cache migration completed successfully. Using isolated @openhax/codex cache."); + } catch (error) { + // No legacy files or migration failed - continue normally + const err = error as Error & { code?: string }; + if (err.code !== "ENOENT") { + logWarn("Cache migration failed, will continue with fresh cache", { error: err.message }); + } + } +} + +/** + * Validate cache format and detect conflicts + * @param cachedMeta - Cache metadata to validate + * @returns True if cache appears to be from our plugin + */ +function validateCacheFormat(cachedMeta: OpenCodeCacheMeta | null): boolean { + if (!cachedMeta) return false; + + // Check if cache has expected structure for our plugin + // Legacy caches might have different URL or missing fields + const hasValidStructure = Boolean( + cachedMeta.etag && + typeof cachedMeta.lastChecked === "number" && + (cachedMeta.url === undefined || cachedMeta.url?.includes("sst/opencode")), + ); + + return hasValidStructure; +} + +/** + * Fetch OpenCode's codex.txt prompt with ETag-based caching and conflict resolution * Uses HTTP conditional requests to efficiently check for updates * * Rate limit protection: Only checks GitHub if cache is older than 15 minutes @@ -32,6 +92,7 @@ export async function getOpenCodeCodexPrompt(): Promise { const cacheDir = getOpenCodePath("cache"); const cacheFilePath = getOpenCodePath("cache", CACHE_FILES.OPENCODE_CODEX); const cacheMetaPath = getOpenCodePath("cache", CACHE_FILES.OPENCODE_CODEX_META); + // Ensure cache directory exists (test expects mkdir to be called) await mkdir(cacheDir, { recursive: true }); @@ -43,6 +104,9 @@ export async function getOpenCodeCodexPrompt(): Promise { } recordCacheMiss("opencodePrompt"); + // Check for and migrate legacy cache files only when session cache misses + await migrateLegacyCache(cacheDir); + // Try to load cached content and metadata let cachedContent: string | null = null; let cachedMeta: OpenCodeCacheMeta | null = null; @@ -53,14 +117,28 @@ export async function getOpenCodeCodexPrompt(): Promise { cachedMeta = JSON.parse(metaContent); } catch (error) { // Cache doesn't exist or is invalid, will fetch fresh - const err = error as Error; - logError("Failed to read OpenCode prompt cache", { error: err.message }); + const err = error as Error & { code?: string }; + if (err.code !== "ENOENT") { + logError("Failed to read OpenCode prompt cache", { error: err.message }); + } + } + + // Validate cache format and handle conflicts + if (cachedMeta && !validateCacheFormat(cachedMeta)) { + logWarn("Detected incompatible cache format. Creating fresh cache for @openhax/codex...", { + cacheSource: cachedMeta.url || "unknown", + pluginPrefix: PLUGIN_PREFIX, + }); + + // Reset cache variables to force fresh fetch + cachedContent = null; + cachedMeta = null; } - // Rate limit protection: If cache is less than 15 minutes old, use it + // Rate limit protection: If cache is less than 15 minutes old and valid, use it if (cachedMeta?.lastChecked && Date.now() - cachedMeta.lastChecked < CACHE_TTL_MS && cachedContent) { // Store in session cache for faster subsequent access - openCodePromptCache.set("main", { data: cachedContent, etag: cachedMeta.etag || undefined }); + openCodePromptCache.set("main", { data: cachedContent, etag: cachedMeta?.etag || undefined }); return cachedContent; } @@ -85,7 +163,7 @@ export async function getOpenCodeCodexPrompt(): Promise { const content = await response.text(); const etag = response.headers.get("etag") || ""; - // Save to cache with timestamp + // Save to cache with timestamp and plugin identifier await writeFile(cacheFilePath, content, "utf-8"); await writeFile( cacheMetaPath, @@ -94,6 +172,7 @@ export async function getOpenCodeCodexPrompt(): Promise { 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, @@ -109,6 +188,11 @@ export async function getOpenCodeCodexPrompt(): Promise { // 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; } @@ -119,11 +203,25 @@ export async function getOpenCodeCodexPrompt(): Promise { // 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; } + // 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*`, + ); + } + throw new Error(`Failed to fetch OpenCode codex.txt and no cache available: ${err.message}`); } } @@ -139,8 +237,10 @@ export async function getCachedPromptPrefix(chars = 50): Promise const content = await readFile(filePath, "utf-8"); return content.substring(0, chars); } catch (error) { - const err = error as Error; - logError("Failed to read cached OpenCode prompt prefix", { error: err.message }); + const err = error as Error & { code?: string }; + if (err.code !== "ENOENT") { + logError("Failed to read cached OpenCode prompt prefix", { error: err.message }); + } return null; } } diff --git a/lib/utils/cache-config.ts b/lib/utils/cache-config.ts index dece0a5..319bd2e 100644 --- a/lib/utils/cache-config.ts +++ b/lib/utils/cache-config.ts @@ -21,16 +21,35 @@ export const CACHE_DIRS = { } as const; /** - * Cache file names + * Plugin identifier for cache isolation + */ +export const PLUGIN_PREFIX = "openhax-codex"; + +/** + * Cache file names with plugin-specific prefix */ export const CACHE_FILES = { /** Codex instructions file */ - CODEX_INSTRUCTIONS: "codex-instructions.md", + CODEX_INSTRUCTIONS: `${PLUGIN_PREFIX}-instructions.md`, /** Codex instructions metadata file */ - CODEX_INSTRUCTIONS_META: "codex-instructions-meta.json", + CODEX_INSTRUCTIONS_META: `${PLUGIN_PREFIX}-instructions-meta.json`, /** OpenCode prompt file */ - OPENCODE_CODEX: "opencode-codex.txt", + OPENCODE_CODEX: `${PLUGIN_PREFIX}-opencode-prompt.txt`, /** OpenCode prompt metadata file */ + OPENCODE_CODEX_META: `${PLUGIN_PREFIX}-opencode-prompt-meta.json`, +} as const; + +/** + * Legacy cache file names (for migration) + */ +export const LEGACY_CACHE_FILES = { + /** Legacy Codex instructions file */ + CODEX_INSTRUCTIONS: "codex-instructions.md", + /** Legacy Codex instructions metadata file */ + CODEX_INSTRUCTIONS_META: "codex-instructions-meta.json", + /** Legacy OpenCode prompt file */ + OPENCODE_CODEX: "opencode-codex.txt", + /** Legacy OpenCode prompt metadata file */ OPENCODE_CODEX_META: "opencode-codex-meta.json", } as const; diff --git a/package.json b/package.json index e6c4ad7..b3ef02f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openhax/codex", - "version": "0.0.0", + "version": "0.2.0", "description": "OpenHax Codex OAuth plugin for Opencode — bring your ChatGPT Plus/Pro subscription instead of API credits", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/spec/issue-25-oauth-cache-conflicts.md b/spec/issue-25-oauth-cache-conflicts.md new file mode 100644 index 0000000..2430573 --- /dev/null +++ b/spec/issue-25-oauth-cache-conflicts.md @@ -0,0 +1,158 @@ +# Issue 25 – OAuth Cache Conflicts Between Plugins + +**Issue**: #25 (BUG) Plugin fails with confusing errors if started with the other oauth plugin's cache files + +## Context & Current Behavior + +- **Problem**: Users switching from `opencode-openai-codex-auth` to `@openhax/codex` encounter cache conflicts +- **Root Cause**: Both plugins use the same cache directory (`~/.opencode/cache/`) but with different: + - Cache file formats + - Fetch URLs (different GitHub repositories) + - Metadata structures +- **Error Message**: `Failed to fetch OpenCode codex.txt: 404 Failed to fetch OpenCode codex.txt from GitHub` +- **User Impact**: Poor conversion experience, users think our plugin is broken + +## Current Cache Files + +- `lib/prompts/opencode-codex.ts:31-129` - Fetches from `sst/opencode` repo +- `lib/utils/cache-config.ts:26-35` - Defines cache file names: + - `opencode-codex.txt` - OpenCode prompt content + - `opencode-codex-meta.json` - ETag and metadata +- Cache location: `~/.opencode/cache/` (shared with other plugin) + +## Solution Strategy + +### 1. Plugin-Specific Cache Namespace + +**Goal**: Isolate our cache files from other plugins +**Implementation**: + +- Add plugin identifier prefix to cache files +- Use `openhax-codex-` prefix for all cache files +- Update cache paths to use plugin-specific subdirectory + +### 2. Graceful Cache Migration & Validation + +**Goal**: Handle existing cache files gracefully +**Implementation**: + +- Detect incompatible cache formats +- Provide clear migration messages +- Fallback to fresh fetch when cache is invalid +- Don't fail with cryptic errors + +### 3. Enhanced Error Handling + +**Goal**: Better user experience during plugin switching +**Implementation**: + +- Detect cache conflict scenarios +- Provide helpful error messages +- Suggest cache cleanup steps +- Continue operation when possible + +## Implementation Plan + +### Phase 1: Plugin-Specific Cache Files + +1. Update `lib/utils/cache-config.ts`: + - Add plugin-specific cache file names + - Use `openhax-codex-` prefix +2. Update `lib/prompts/opencode-codex.ts`: + - Use new cache file paths + - Maintain backward compatibility during migration +3. Update `lib/prompts/codex.ts`: + - Apply same prefix to Codex instruction cache + +### Phase 2: Cache Validation & Migration + +1. Add cache format validation: + - Check if cache files are from our plugin + - Detect incompatible formats +2. Implement graceful migration: + - Backup existing cache if needed + - Create fresh cache files + - Log migration actions + +### Phase 3: Enhanced Error Messages + +1. Improve error handling in `lib/prompts/opencode-codex.ts`: + - Detect cache conflict scenarios + - Provide actionable error messages +2. Add cache cleanup guidance: + - Suggest manual cleanup steps + - Include commands for cache reset + +## Definition of Done / Requirements + +### Functional Requirements + +- [ ] Plugin uses isolated cache files with `openhax-codex-` prefix +- [ ] Graceful handling of existing cache from other plugins +- [ ] Clear error messages when cache conflicts are detected +- [ ] Automatic cache migration without user intervention +- [ ] Fallback to fresh fetch when cache is incompatible + +### Non-Functional Requirements + +- [ ] No breaking changes for existing users of our plugin +- [ ] Backward compatibility with our current cache format +- [ ] Performance impact is minimal (cache isolation overhead) +- [ ] Error messages are actionable and user-friendly + +### Test Coverage + +- [ ] Tests for cache file isolation +- [ ] Tests for cache migration scenarios +- [ ] Tests for error handling with invalid cache +- [ ] Tests for backward compatibility +- [ ] Integration tests for plugin switching scenarios + +## Files to Modify + +### Core Changes + +- `lib/utils/cache-config.ts` - Plugin-specific cache file names +- `lib/prompts/opencode-codex.ts` - Isolated cache paths + validation +- `lib/prompts/codex.ts` - Apply prefix to Codex cache files + +### Test Updates + +- `test/prompts-opencode-codex.test.ts` - Update cache file paths +- `test/prompts-codex.test.ts` - Test cache isolation +- Add new tests for cache migration and conflict handling + +## User Experience Improvements + +### Before (Current) + +``` +ERROR Failed to fetch OpenCode codex.txt: 404 Failed to fetch OpenCode codex.txt from GitHub +``` + +### After (Target) + +``` +WARN Detected cache files from different plugin. Creating fresh cache for @openhax/codex... +INFO Cache migration completed successfully. +INFO Ready to use @openhax/codex with isolated cache. +``` + +## Migration Strategy + +### For New Users + +- No impact - will start with clean, isolated cache + +### For Existing Users + +- Automatic migration on first run +- Preserve existing cache in backup location +- No manual intervention required +- Clear communication about migration + +### For Users Switching Between Plugins + +- Graceful cache conflict detection +- Actionable error messages +- Simple cache cleanup commands if needed diff --git a/test/prompts-codex.test.ts b/test/prompts-codex.test.ts index 9496d55..c20c3e9 100644 --- a/test/prompts-codex.test.ts +++ b/test/prompts-codex.test.ts @@ -33,8 +33,8 @@ vi.mock("node:os", () => ({ describe("Codex Instructions Fetcher", () => { const cacheDir = join("/mock-home", ".opencode", "cache"); - const cacheFile = join(cacheDir, "codex-instructions.md"); - const cacheMeta = join(cacheDir, "codex-instructions-meta.json"); + const cacheFile = join(cacheDir, "openhax-codex-instructions.md"); + const cacheMeta = join(cacheDir, "openhax-codex-instructions-meta.json"); beforeEach(() => { files.clear(); diff --git a/test/prompts-opencode-codex.test.ts b/test/prompts-opencode-codex.test.ts index e407ee4..16c89c5 100644 --- a/test/prompts-opencode-codex.test.ts +++ b/test/prompts-opencode-codex.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { join } from 'node:path'; -import { openCodePromptCache } from '../lib/cache/session-cache.js'; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { join } from "node:path"; +import { openCodePromptCache } from "../lib/cache/session-cache.js"; const files = new Map(); const readFileMock = vi.fn(); const writeFileMock = vi.fn(); const mkdirMock = vi.fn(); -const homedirMock = vi.fn(() => '/mock-home'); +const homedirMock = vi.fn(() => "/mock-home"); const fetchMock = vi.fn(); const recordCacheHitMock = vi.fn(); const recordCacheMissMock = vi.fn(); @@ -15,13 +15,13 @@ const appendFileSync = vi.fn(); const writeFileSync = vi.fn(); const mkdirSync = vi.fn(); -vi.mock('node:fs/promises', () => ({ +vi.mock("node:fs/promises", () => ({ mkdir: mkdirMock, readFile: readFileMock, writeFile: writeFileMock, })); -vi.mock('node:fs', () => ({ +vi.mock("node:fs", () => ({ default: { existsSync, appendFileSync, @@ -34,12 +34,12 @@ vi.mock('node:fs', () => ({ mkdirSync, })); -vi.mock('node:os', () => ({ +vi.mock("node:os", () => ({ __esModule: true, homedir: homedirMock, })); -vi.mock('../lib/cache/session-cache.js', () => ({ +vi.mock("../lib/cache/session-cache.js", () => ({ openCodePromptCache: { get: vi.fn(), set: vi.fn(), @@ -48,22 +48,22 @@ vi.mock('../lib/cache/session-cache.js', () => ({ getOpenCodeCacheKey: vi.fn(), })); -vi.mock('../lib/cache/cache-metrics.js', () => ({ +vi.mock("../lib/cache/cache-metrics.js", () => ({ recordCacheHit: recordCacheHitMock, recordCacheMiss: recordCacheMissMock, })); -describe('OpenCode Codex Prompt Fetcher', () => { - const cacheDir = join('/mock-home', '.opencode', 'cache'); - const cacheFile = join(cacheDir, 'opencode-codex.txt'); - const cacheMetaFile = join(cacheDir, 'opencode-codex-meta.json'); +describe("OpenCode Codex Prompt Fetcher", () => { + const cacheDir = join("/mock-home", ".opencode", "cache"); + const cacheFile = join(cacheDir, "openhax-codex-opencode-prompt.txt"); + const cacheMetaFile = join(cacheDir, "openhax-codex-opencode-prompt-meta.json"); beforeEach(() => { files.clear(); readFileMock.mockClear(); writeFileMock.mockClear(); mkdirMock.mockClear(); - homedirMock.mockReturnValue('/mock-home'); + homedirMock.mockReturnValue("/mock-home"); fetchMock.mockClear(); recordCacheHitMock.mockClear(); recordCacheMissMock.mockClear(); @@ -72,269 +72,280 @@ describe('OpenCode Codex Prompt Fetcher', () => { writeFileSync.mockReset(); mkdirSync.mockReset(); openCodePromptCache.clear(); - vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); - describe('getOpenCodeCodexPrompt', () => { - it('returns cached content from session cache when available', async () => { - const cachedData = 'cached-prompt-content'; - openCodePromptCache.get = vi.fn().mockReturnValue({ data: cachedData, etag: 'etag-123' }); + describe("getOpenCodeCodexPrompt", () => { + it("returns cached content from session cache when available", async () => { + const cachedData = "cached-prompt-content"; + openCodePromptCache.get = vi.fn().mockReturnValue({ data: cachedData, etag: "etag-123" }); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); const result = await getOpenCodeCodexPrompt(); expect(result).toBe(cachedData); - expect(recordCacheHitMock).toHaveBeenCalledWith('opencodePrompt'); + expect(recordCacheHitMock).toHaveBeenCalledWith("opencodePrompt"); expect(recordCacheMissMock).not.toHaveBeenCalled(); expect(readFileMock).not.toHaveBeenCalled(); + expect(mkdirMock).toHaveBeenCalled(); // Should still call mkdir for cache directory }); - it('falls back to file cache when session cache misses', async () => { + it("falls back to file cache when session cache misses", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); - const cachedContent = 'file-cached-content'; + const cachedContent = "file-cached-content"; const cachedMeta = { etag: '"file-etag"', lastChecked: Date.now() - 20 * 60 * 1000 }; // 20 minutes ago (outside TTL) readFileMock.mockImplementation((path) => { if (path === cacheFile) return Promise.resolve(cachedContent); if (path === cacheMetaFile) return Promise.resolve(JSON.stringify(cachedMeta)); - return Promise.reject(new Error('File not found')); + return Promise.reject(new Error("File not found")); }); - fetchMock.mockResolvedValue(new Response('fresh-content', { - status: 200, - headers: { etag: '"new-etag"' } - })); + fetchMock.mockResolvedValue( + new Response("fresh-content", { + status: 200, + headers: { etag: '"new-etag"' }, + }), + ); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); const result = await getOpenCodeCodexPrompt(); - expect(result).toBe('fresh-content'); - expect(recordCacheMissMock).toHaveBeenCalledWith('opencodePrompt'); + expect(result).toBe("fresh-content"); + expect(recordCacheMissMock).toHaveBeenCalledWith("opencodePrompt"); expect(writeFileMock).toHaveBeenCalledTimes(2); // Check that both files were written (order doesn't matter) const writeCalls = writeFileMock.mock.calls; expect(writeCalls).toHaveLength(2); - + // Find calls by file path - const contentFileCall = writeCalls.find(call => call[0] === cacheFile); - const metaFileCall = writeCalls.find(call => call[0] === cacheMetaFile); - + const contentFileCall = writeCalls.find((call) => call[0] === cacheFile); + const metaFileCall = writeCalls.find((call) => call[0] === cacheMetaFile); + expect(contentFileCall).toBeTruthy(); expect(metaFileCall).toBeTruthy(); - expect(contentFileCall![1]).toBe('fresh-content'); - expect(contentFileCall![2]).toBe('utf-8'); - expect(metaFileCall![2]).toBe('utf-8'); - expect(metaFileCall![1]).toContain('new-etag'); + expect(contentFileCall![1]).toBe("fresh-content"); + expect(contentFileCall![2]).toBe("utf-8"); + expect(metaFileCall![2]).toBe("utf-8"); + expect(metaFileCall![1]).toContain("new-etag"); }); - it('uses file cache when within TTL period', async () => { + it("uses file cache when within TTL period", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); - const cachedContent = 'recent-cache-content'; + const cachedContent = "recent-cache-content"; const recentTime = Date.now() - 5 * 60 * 1000; // 5 minutes ago const cachedMeta = { etag: '"recent-etag"', lastChecked: recentTime }; readFileMock.mockImplementation((path) => { if (path === cacheFile) return Promise.resolve(cachedContent); if (path === cacheMetaFile) return Promise.resolve(JSON.stringify(cachedMeta)); - return Promise.reject(new Error('File not found')); + return Promise.reject(new Error("File not found")); }); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); const result = await getOpenCodeCodexPrompt(); expect(result).toBe(cachedContent); expect(fetchMock).not.toHaveBeenCalled(); - expect(openCodePromptCache.set).toHaveBeenCalledWith('main', { + expect(openCodePromptCache.set).toHaveBeenCalledWith("main", { data: cachedContent, - etag: '"recent-etag"' + etag: '"recent-etag"', }); }); - it('handles 304 Not Modified response', async () => { + it("handles 304 Not Modified response", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); - const cachedContent = 'not-modified-content'; + const cachedContent = "not-modified-content"; const oldTime = Date.now() - 20 * 60 * 1000; // 20 minutes ago const cachedMeta = { etag: '"old-etag"', lastChecked: oldTime }; readFileMock.mockImplementation((path) => { if (path === cacheFile) return Promise.resolve(cachedContent); if (path === cacheMetaFile) return Promise.resolve(JSON.stringify(cachedMeta)); - return Promise.reject(new Error('File not found')); + return Promise.reject(new Error("File not found")); }); - fetchMock.mockResolvedValue(new Response(null, { - status: 304, - headers: {} - })); + fetchMock.mockResolvedValue( + new Response(null, { + status: 304, + headers: {}, + }), + ); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); const result = await getOpenCodeCodexPrompt(); expect(result).toBe(cachedContent); expect(fetchMock).toHaveBeenCalledTimes(1); const fetchCall = fetchMock.mock.calls[0]; - expect(fetchCall[0]).toContain('github'); - expect(typeof fetchCall[1]).toBe('object'); - expect(fetchCall[1]).toHaveProperty('headers'); - expect((fetchCall[1] as any).headers).toEqual({ 'If-None-Match': '"old-etag"' }); + expect(fetchCall[0]).toContain("github"); + expect(typeof fetchCall[1]).toBe("object"); + expect(fetchCall[1]).toHaveProperty("headers"); + expect((fetchCall[1] as any).headers).toEqual({ "If-None-Match": '"old-etag"' }); }); - it('handles fetch failure with fallback to cache', async () => { + it("handles fetch failure with fallback to cache", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); - const cachedContent = 'fallback-content'; + const cachedContent = "fallback-content"; const oldTime = Date.now() - 20 * 60 * 1000; const cachedMeta = { etag: '"fallback-etag"', lastChecked: oldTime }; readFileMock.mockImplementation((path) => { if (path === cacheFile) return Promise.resolve(cachedContent); if (path === cacheMetaFile) return Promise.resolve(JSON.stringify(cachedMeta)); - return Promise.reject(new Error('File not found')); + return Promise.reject(new Error("File not found")); }); - fetchMock.mockRejectedValue(new Error('Network error')); + fetchMock.mockRejectedValue(new Error("Network error")); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); const result = await getOpenCodeCodexPrompt(); expect(result).toBe(cachedContent); - expect(openCodePromptCache.set).toHaveBeenCalledWith('main', { + expect(openCodePromptCache.set).toHaveBeenCalledWith("main", { data: cachedContent, - etag: '"fallback-etag"' + etag: '"fallback-etag"', }); }); - it('throws error when no cache available and fetch fails', async () => { + it("throws error when no cache available and fetch fails", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); - readFileMock.mockRejectedValue(new Error('No cache file')); + readFileMock.mockRejectedValue(new Error("No cache file")); - fetchMock.mockRejectedValue(new Error('Network error')); + fetchMock.mockRejectedValue(new Error("Network error")); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); await expect(getOpenCodeCodexPrompt()).rejects.toThrow( - 'Failed to fetch OpenCode codex.txt and no cache available' + "Failed to fetch OpenCode codex.txt and no cache available", ); }); - it('handles non-200 response status with fallback to cache', async () => { + it("handles non-200 response status with fallback to cache", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); - const cachedContent = 'error-fallback-content'; + const cachedContent = "error-fallback-content"; const oldTime = Date.now() - 20 * 60 * 1000; const cachedMeta = { etag: '"error-etag"', lastChecked: oldTime }; readFileMock.mockImplementation((path) => { if (path === cacheFile) return Promise.resolve(cachedContent); if (path === cacheMetaFile) return Promise.resolve(JSON.stringify(cachedMeta)); - return Promise.reject(new Error('File not found')); + return Promise.reject(new Error("File not found")); }); - fetchMock.mockResolvedValue(new Response('Error', { status: 500 })); + fetchMock.mockResolvedValue(new Response("Error", { status: 500 })); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); const result = await getOpenCodeCodexPrompt(); expect(result).toBe(cachedContent); }); - it('creates cache directory when it does not exist', async () => { + it("creates cache directory when it does not exist", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); - readFileMock.mockRejectedValue(new Error('No cache files')); - fetchMock.mockResolvedValue(new Response('new-content', { - status: 200, - headers: { etag: '"new-etag"' } - })); + readFileMock.mockRejectedValue(new Error("No cache files")); + fetchMock.mockResolvedValue( + new Response("new-content", { + status: 200, + headers: { etag: '"new-etag"' }, + }), + ); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); await getOpenCodeCodexPrompt(); expect(mkdirMock).toHaveBeenCalledWith(cacheDir, { recursive: true }); }); - it('handles missing etag in response', async () => { + it("handles missing etag in response", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); - readFileMock.mockRejectedValue(new Error('No cache files')); - fetchMock.mockResolvedValue(new Response('no-etag-content', { - status: 200, - headers: {} // No etag header - })); + readFileMock.mockRejectedValue(new Error("No cache files")); + fetchMock.mockResolvedValue( + new Response("no-etag-content", { + status: 200, + headers: {}, // No etag header + }), + ); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); const result = await getOpenCodeCodexPrompt(); - expect(result).toBe('no-etag-content'); + expect(result).toBe("no-etag-content"); expect(writeFileMock).toHaveBeenCalledWith( cacheMetaFile, expect.stringContaining('"etag": ""'), - 'utf-8' + "utf-8", ); }); - it('handles malformed cache metadata', async () => { + it("handles malformed cache metadata", async () => { openCodePromptCache.get = vi.fn().mockReturnValue(undefined); - const cachedContent = 'good-content'; + const cachedContent = "good-content"; readFileMock.mockImplementation((path) => { if (path === cacheFile) return Promise.resolve(cachedContent); - if (path === cacheMetaFile) return Promise.resolve('invalid json'); - return Promise.reject(new Error('File not found')); + if (path === cacheMetaFile) return Promise.resolve("invalid json"); + return Promise.reject(new Error("File not found")); }); - fetchMock.mockResolvedValue(new Response('fresh-content', { - status: 200, - headers: { etag: '"fresh-etag"' } - })); + fetchMock.mockResolvedValue( + new Response("fresh-content", { + status: 200, + headers: { etag: '"fresh-etag"' }, + }), + ); - const { getOpenCodeCodexPrompt } = await import('../lib/prompts/opencode-codex.js'); + const { getOpenCodeCodexPrompt } = await import("../lib/prompts/opencode-codex.js"); const result = await getOpenCodeCodexPrompt(); - expect(result).toBe('fresh-content'); + expect(result).toBe("fresh-content"); }); }); - describe('getCachedPromptPrefix', () => { - it('returns first N characters of cached content', async () => { - const fullContent = 'This is the full cached prompt content for testing'; + describe("getCachedPromptPrefix", () => { + it("returns first N characters of cached content", async () => { + const fullContent = "This is the full cached prompt content for testing"; readFileMock.mockResolvedValue(fullContent); - const { getCachedPromptPrefix } = await import('../lib/prompts/opencode-codex.js'); + const { getCachedPromptPrefix } = await import("../lib/prompts/opencode-codex.js"); const result = await getCachedPromptPrefix(10); - expect(result).toBe('This is th'); - expect(readFileMock).toHaveBeenCalledWith(cacheFile, 'utf-8'); + expect(result).toBe("This is th"); + expect(readFileMock).toHaveBeenCalledWith(cacheFile, "utf-8"); }); - it('returns null when cache file does not exist', async () => { - readFileMock.mockRejectedValue(new Error('File not found')); + it("returns null when cache file does not exist", async () => { + readFileMock.mockRejectedValue(new Error("File not found")); - const { getCachedPromptPrefix } = await import('../lib/prompts/opencode-codex.js'); + const { getCachedPromptPrefix } = await import("../lib/prompts/opencode-codex.js"); const result = await getCachedPromptPrefix(); expect(result).toBeNull(); }); - it('uses default character count when not specified', async () => { - const fullContent = 'A'.repeat(100); + it("uses default character count when not specified", async () => { + const fullContent = "A".repeat(100); readFileMock.mockResolvedValue(fullContent); - const { getCachedPromptPrefix } = await import('../lib/prompts/opencode-codex.js'); + const { getCachedPromptPrefix } = await import("../lib/prompts/opencode-codex.js"); const result = await getCachedPromptPrefix(); - expect(result).toBe('A'.repeat(50)); + expect(result).toBe("A".repeat(50)); }); - it('handles content shorter than requested characters', async () => { - const shortContent = 'Short'; + it("handles content shorter than requested characters", async () => { + const shortContent = "Short"; readFileMock.mockResolvedValue(shortContent); - const { getCachedPromptPrefix } = await import('../lib/prompts/opencode-codex.js'); + const { getCachedPromptPrefix } = await import("../lib/prompts/opencode-codex.js"); const result = await getCachedPromptPrefix(20); - expect(result).toBe('Short'); + expect(result).toBe("Short"); }); }); -}); \ No newline at end of file +});