import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } 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 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/claude-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: 'claude-sonnet-4', reasoningEffort: 'medium' }, aiProviderProfile: { id: 'p', name: 'Anthropic', provider: 'anthropic', authType: 'api_key', secret: 'sk-ant-test', model: 'claude-sonnet-4', reasoningEffort: 'medium', }, github: {}, secrets: [], } as unknown as Claim; const makeWorkspace = (): AdapterWorkspace => ({ claim, workdir: '/tmp/spoon-claude-adapter-test-missing', homeDir: '/tmp/spoon-claude-adapter-test-missing', username: 'tester', containerHome: '/home/tester', containerRepo: '/home/tester/Code/spoon', repoDir: '/tmp/spoon-claude-adapter-test-missing/Code/spoon', boxName: 'spoon-box-tester', redact: (value: string) => value, runtime: 'claude', }); const makeOAuthWorkspace = ( homeDir: string, authType: 'anthropic_oauth_json' | 'opencode_auth_json', ): AdapterWorkspace => ({ claim: { ...claim, aiProviderProfile: { id: 'p', name: 'Anthropic', provider: 'anthropic', authType, secret: JSON.stringify({ access_token: 'oauth-token' }), model: 'claude-sonnet-4', reasoningEffort: 'medium', }, } as unknown as Claim, workdir: homeDir, homeDir, username: 'tester', containerHome: '/home/tester', containerRepo: '/home/tester/Code/spoon', repoDir: path.join(homeDir, 'Code/spoon'), boxName: 'spoon-box-tester', redact: (value: string) => value, runtime: 'claude', }); const tempDirs: string[] = []; afterEach(async () => { while (tempDirs.length) { const dir = tempDirs.pop(); if (dir) await rm(dir, { recursive: true, force: true }); } }); describe('ClaudeCodeAdapter prepareAuth', () => { test('writes credentials.json for an anthropic_oauth_json profile', async () => { const { createClaudeAdapter } = await loadAdapter(); const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-')); tempDirs.push(homeDir); const adapter = createClaudeAdapter(); await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'anthropic_oauth_json')); const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); const contents = await readFile(credentialsPath, 'utf8'); expect(contents).toContain('oauth-token'); }); test('does not write credentials.json for an opencode_auth_json profile', async () => { const { createClaudeAdapter } = await loadAdapter(); const homeDir = await mkdtemp(path.join(tmpdir(), 'spoon-claude-oauth-')); tempDirs.push(homeDir); const adapter = createClaudeAdapter(); await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'opencode_auth_json')); const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); await expect(stat(credentialsPath)).rejects.toThrow(); }); }); describe('ClaudeCodeAdapter', () => { test('streams normalized events and marks the turn via buildMarkedCommand', async () => { const { createClaudeAdapter } = await loadAdapter(); const captured: unknown[] = []; const execStream = vi.fn(async (args) => { captured.push(args.command); await args.onStdoutLine?.( JSON.stringify({ type: 'system', subtype: 'init', session_id: 'abc', }), ); await args.onStdoutLine?.( JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'hi there' }] }, }), ); await args.onStdoutLine?.( JSON.stringify({ type: 'result', subtype: 'success', result: 'final text', session_id: 'abc', }), ); return { exitCode: 0, output: '' }; }) as unknown as ExecStreamFn; const adapter = createClaudeAdapter({ 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: 'session', sessionId: 'abc' }); expect(events).toContainEqual({ kind: 'assistant_delta', content: 'hi there', }); expect(result.finalMessage).toBe('final text'); expect(result.sessionId).toBe('abc'); expect(result.error).toBeUndefined(); // The turn ran through buildMarkedCommand (setsid bash session) carrying the // `claude -p` argv in the script. const command = captured[0] as string[]; expect(command[0]).toBe('setsid'); expect(command[1]).toBe('bash'); expect(command[3]).toContain("'claude' '-p'"); expect(command[3]).toContain("'--output-format' 'stream-json'"); }); });