Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 114 additions & 2 deletions src/model/provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { createServer, type AddressInfo } from 'node:net';
import { mkdirSync, writeFileSync } from 'node:fs';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawn, type ChildProcess } from 'node:child_process';
Expand Down Expand Up @@ -34,7 +34,7 @@ export interface Provider {
export const validateModel = (model: string): void => {
if (!model.includes('/')) {
throw new Error(
`Invalid model format "${model}". Expected "provider/model" (e.g. github-copilot/claude-sonnet-4.6, anthropic/claude-sonnet-4-6, claude/sonnet).`,
`Invalid model format "${model}". Expected "provider/model" (e.g. github-copilot/claude-sonnet-4.6, anthropic/claude-sonnet-4-6, claude/sonnet, codex/gpt-5.5).`,
);
}

Expand All @@ -49,6 +49,7 @@ export const validateModel = (model: string): void => {
export const createProvider = (model: string): Provider => {
validateModel(model);
if (model.startsWith('claude/')) return new ClaudeCliProvider(model);
if (model.startsWith('codex/')) return new CodexCliProvider(model);
if (model.startsWith('anthropic/')) return new AnthropicProvider(model);
return new OpenCodeProvider(model);
};
Expand Down Expand Up @@ -447,6 +448,117 @@ export class ClaudeCliProvider implements Provider {
}
}

export class CodexCliProvider implements Provider {
private readonly modelAlias: string;
private readonly debug: boolean;

constructor(model: string = 'codex/gpt-5.5') {
const slashIdx = model.indexOf('/');
this.modelAlias = slashIdx !== -1 ? model.slice(slashIdx + 1) : model;
this.debug = process.env.AICC_DEBUG === 'true';
}

name() {
return 'codex-cli';
}

warmup(): void {}

async close(): Promise<void> {}

async chat(messages: ChatMessage[], _opts?: { maxTokens?: number; temperature?: number }) {
if (process.env.AICC_DEBUG_PROVIDER === 'mock') {
return JSON.stringify({
commits: [
{
title: 'chore: mock commit from provider',
body: '',
score: 80,
reasons: ['mock mode'],
},
],
meta: { splitRecommended: false },
});
}

const prompt = messages.map((m) => `${m.role.toUpperCase()}: ${m.content}`).join('\n\n');

// `codex exec` is an agent whose stdout is a live trace, not a clean result,
// so `--output-last-message` writes only the final message to a file and we
// read the answer from disk. We deliberately do NOT pass `--output-schema`:
// it feeds OpenAI strict structured outputs, which reject our shared schema
// (they require additionalProperties:false on every object). Instead we rely
// on the system prompt's embedded schema + "Return ONLY the JSON object",
// exactly as the `claude` provider does, and let extractJSON parse it.
// read-only + ephemeral keep it a pure completion that never writes or persists.
const dir = join(tmpdir(), `codex-aicc-${process.pid}`);
mkdirSync(dir, { recursive: true });
const outPath = join(dir, 'last-message.txt');

const args = [
'exec',
'--model',
this.modelAlias,
'--sandbox',
'read-only',
'--skip-git-repo-check',
'--ephemeral',
'--color',
'never',
'--output-last-message',
outPath,
'-', // read the prompt from stdin
];

if (this.debug)
pdbg('spawning codex cli', { model: this.modelAlias, promptChars: prompt.length });

return new Promise<string>((resolve, reject) => {
const cleanup = () => {
try {
rmSync(dir, { recursive: true, force: true });
} catch {}
};

const proc = spawn('codex', args);

proc.stdin?.write(prompt);
proc.stdin?.end();

let stderr = '';
// stdout is the agent's live trace — ignored in favour of --output-last-message.
proc.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});

proc.on('error', (err) => {
cleanup();
reject(err);
});
proc.on('exit', (code) => {
if (code !== 0) {
cleanup();
reject(new Error(`codex cli exited with code ${code}: ${stderr}`));
return;
}
try {
const result = readFileSync(outPath, 'utf8').trim();
if (!result) {
reject(new Error(`codex cli produced no final message.\n${stderr}`));
return;
}
if (this.debug) pdbg('codex cli response', { resultChars: result.length });
resolve(result);
} catch (e: any) {
reject(new Error(`Failed to read codex cli output: ${e.message}\n${stderr}`));
} finally {
cleanup();
}
});
});
}
}

export class AnthropicProvider implements Provider {
private readonly modelID: string;
private readonly debug: boolean;
Expand Down
16 changes: 12 additions & 4 deletions src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ const matchesPattern = (filePath: string, pattern: string): boolean => {

const MAX_DIFF_CHARS = 80_000;

const tag = (module: string) =>
chalk.dim('[ai-cc]') + chalk.cyan(`[${module}]`);
const tag = (module: string) => chalk.dim('[ai-cc]') + chalk.cyan(`[${module}]`);

const kv = (k: string, v: unknown) => chalk.dim(k + '=') + chalk.yellow(String(v));

const dbg = (module: string, msg: string, pairs: Record<string, unknown> = {}) => {
if (process.env.AICC_DEBUG !== 'true') return;
const kvStr = Object.entries(pairs).map(([k, v]) => kv(k, v)).join(' ');
const kvStr = Object.entries(pairs)
.map(([k, v]) => kv(k, v))
.join(' ');
console.error(tag(module), chalk.white(msg), kvStr || '');
};

Expand Down Expand Up @@ -180,6 +181,9 @@ export const buildGenerationMessages = (opts: {
specLines.push(
'Length Rule: Keep titles concise; prefer 50 or fewer chars; MUST be <=72 including type/scope.',
);
specLines.push(
'Body Rules (REQUIRED for non-trivial changes): Populate "body" with 2-5 concise bullet points (each line starting with "- ") explaining WHAT changed and WHY, citing concrete files/functions. Leave "body" empty ONLY for genuinely trivial one-line changes (typo, version bump, pure formatting). Never omit the body for feat, fix, refactor, or multi-file changes, regardless of the repo\'s prevailing title-only style.',
);
specLines.push(
'Emoji Rule: ' +
(config.style === 'gitmoji' || config.style === 'gitmoji-pure'
Expand Down Expand Up @@ -217,7 +221,11 @@ export const buildGenerationMessages = (opts: {
},
];

dbg('prompt', 'messages built', { systemChars: messages[0].content.length, userChars: messages[1].content.length, totalChars: messages[0].content.length + messages[1].content.length });
dbg('prompt', 'messages built', {
systemChars: messages[0].content.length,
userChars: messages[1].content.length,
totalChars: messages[0].content.length + messages[1].content.length,
});

return messages;
};
Expand Down
28 changes: 26 additions & 2 deletions test/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { extractJSON } from '../src/model/provider.js';
import { createProvider, CodexCliProvider, extractJSON } from '../src/model/provider.js';

describe('extractJSON', () => {
it('parses valid JSON', () => {
Expand All @@ -18,4 +18,28 @@ Trailing text`;
const raw = `{ "commits": [ { "title": 5 } ] }`;
expect(() => extractJSON(raw)).toThrow();
});
});
});

describe('createProvider', () => {
it('routes codex/ models to the Codex CLI provider', () => {
const provider = createProvider('codex/gpt-5.5');
expect(provider).toBeInstanceOf(CodexCliProvider);
expect(provider.name()).toBe('codex-cli');
});
});

describe('CodexCliProvider', () => {
it('returns a parseable commit plan in mock mode without spawning codex', async () => {
const previous = process.env.AICC_DEBUG_PROVIDER;
process.env.AICC_DEBUG_PROVIDER = 'mock';
try {
const raw = await new CodexCliProvider('codex/gpt-5.5').chat([
{ role: 'user', content: 'diff' },
]);
const plan = extractJSON(raw);
expect(plan.commits.length).toBeGreaterThan(0);
} finally {
process.env.AICC_DEBUG_PROVIDER = previous;
}
});
});
9 changes: 9 additions & 0 deletions test/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,13 @@ describe('prompt generation', () => {
});
expect(msgs[0].content).toMatch(/OPTIONAL single leading gitmoji/);
});
it('requires a body for non-trivial changes', () => {
const msgs = buildGenerationMessages({
files: baseFiles as any,
style: style as any,
config: cfg('standard'),
mode: 'single',
});
expect(msgs[0].content).toMatch(/Body Rules \(REQUIRED for non-trivial changes\)/);
});
});
Loading