208 lines
6.3 KiB
TypeScript
208 lines
6.3 KiB
TypeScript
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<boolean> => {
|
|
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<string, unknown>,
|
|
): Promise<AdapterWorkspace> => {
|
|
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<void> => {
|
|
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');
|
|
});
|
|
});
|