import { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, test, vi } from 'vitest'; import type { NormalizedAgentEvent } from '../../src/agent-events'; import type { ExecStreamFn } from '../../src/runtime/agent-runtime'; import type { AdapterWorkspace, Claim } from '../../src/runtime/provider'; const tempDirs: string[] = []; const pathExists = async (filePath: string): Promise => { try { await stat(filePath); return true; } catch { return false; } }; const loadAdapter = async () => { process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; process.env.GITHUB_APP_ID = '123'; process.env.GITHUB_APP_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----'; return await import('../../src/runtime/codex-adapter'); }; const claim = { job: { _id: 'job-1', prompt: 'hi', baseBranch: 'main', workBranch: 'spoon/x', forkOwner: 'o', forkRepo: 'r', upstreamOwner: 'u', upstreamRepo: 'r', }, spoon: { name: 'Spoon' }, openai: { model: 'gpt-5', reasoningEffort: 'medium' }, aiProviderProfile: { id: 'p', name: 'Codex', provider: 'opencode_openai_login', authType: 'opencode_auth_json', model: 'gpt-5', reasoningEffort: 'medium', }, github: {}, secrets: [], } as unknown as Claim; const makeWorkspace = (): AdapterWorkspace => ({ claim, // A non-existent workdir so the --output-last-message read is a clean ENOENT. workdir: '/tmp/spoon-codex-adapter-test-missing', homeDir: '/tmp/spoon-codex-adapter-test-missing', username: 'tester', containerHome: '/home/tester', containerRepo: '/home/tester/Code/spoon', repoDir: '/tmp/spoon-codex-adapter-test-missing/Code/spoon', boxName: 'spoon-box-tester', redact: (value: string) => value, runtime: 'codex', }); const makeAuthWorkspace = async ( profile: Record, ): Promise => { const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-auth-')); tempDirs.push(workdir); const repoDir = path.join(workdir, 'Code', 'spoon'); await mkdir(repoDir, { recursive: true }); return { ...makeWorkspace(), claim: { ...claim, aiProviderProfile: profile } as unknown as Claim, workdir, homeDir: workdir, repoDir, }; }; describe('CodexAdapter', () => { afterEach(async () => { await Promise.all( tempDirs.map((dir) => rm(dir, { force: true, recursive: true })), ); tempDirs.length = 0; }); test('prepareAuth skips auth.json for an api_key profile', async () => { const { createCodexAdapter } = await loadAdapter(); const adapter = createCodexAdapter(); const workspace = await makeAuthWorkspace({ id: 'p', name: 'OpenAI', provider: 'openai', authType: 'api_key', model: 'gpt-5', // A raw API key, which is deliberately NOT valid JSON. secret: 'sk-test', }); await expect(adapter.prepareAuth?.(workspace)).resolves.toBeUndefined(); const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json'); const openCodeAuthPath = path.join( workspace.workdir, '.local', 'share', 'opencode', 'auth.json', ); await expect(pathExists(codexAuthPath)).resolves.toBe(false); await expect(pathExists(openCodeAuthPath)).resolves.toBe(false); }); test('prepareAuth writes auth.json for a ChatGPT-login profile', async () => { const { createCodexAdapter } = await loadAdapter(); const adapter = createCodexAdapter(); const secret = JSON.stringify({ tokens: { access: 'abc' } }); const workspace = await makeAuthWorkspace({ id: 'p', name: 'Codex', provider: 'opencode_openai_login', authType: 'opencode_auth_json', model: 'gpt-5', secret, }); await adapter.prepareAuth?.(workspace); const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json'); await expect(readFile(codexAuthPath, 'utf8')).resolves.toBe( `${JSON.stringify(JSON.parse(secret), null, 2)}\n`, ); const openCodeAuthPath = path.join( workspace.workdir, '.local', 'share', 'opencode', 'auth.json', ); await expect(readFile(openCodeAuthPath, 'utf8')).resolves.toBe( `${JSON.stringify(JSON.parse(secret), null, 2)}\n`, ); }); test('streams normalized events and marks the turn via buildMarkedCommand', async () => { const { createCodexAdapter } = await loadAdapter(); const captured: unknown[] = []; const execStream = vi.fn(async (args) => { captured.push(args.command); await args.onStdoutLine?.( JSON.stringify({ type: 'item.completed', item: { id: 'item-1', type: 'agent_message', text: 'done' }, }), ); await args.onStdoutLine?.(JSON.stringify({ type: 'turn.completed' })); return { exitCode: 0, output: '' }; }) as unknown as ExecStreamFn; const adapter = createCodexAdapter({ execStream }); const events: NormalizedAgentEvent[] = []; const onEvent = (event: NormalizedAgentEvent): Promise => { events.push(event); return Promise.resolve(); }; const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent); expect(events).toContainEqual({ kind: 'assistant_delta', content: 'done\n\n', externalMessageId: 'item-1', }); expect(events).toContainEqual({ kind: 'assistant_completed' }); expect(result).toBeTypeOf('object'); // The turn ran through buildMarkedCommand (setsid bash session). const command = captured[0] as string[]; expect(command[0]).toBe('setsid'); expect(command[1]).toBe('bash'); }); test('surfaces a turn.failed error while exiting zero', async () => { const { createCodexAdapter } = await loadAdapter(); const execStream = vi.fn(async (args) => { await args.onStdoutLine?.( JSON.stringify({ type: 'turn.failed', error: { message: 'boom' }, }), ); return { exitCode: 0, output: '' }; }) as unknown as ExecStreamFn; const adapter = createCodexAdapter({ execStream }); const result = await adapter.runTurn(makeWorkspace(), 'do it', () => Promise.resolve(), ); expect(result.error).toContain('boom'); }); });