diff --git a/src/model/provider.ts b/src/model/provider.ts index a40cae7..bbd7e9a 100644 --- a/src/model/provider.ts +++ b/src/model/provider.ts @@ -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'; @@ -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).`, ); } @@ -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); }; @@ -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 {} + + 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((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; diff --git a/src/prompt.ts b/src/prompt.ts index 31935e4..491fd1a 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -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 = {}) => { 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 || ''); }; @@ -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' @@ -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; }; diff --git a/test/model.test.ts b/test/model.test.ts index 4f31b4b..5d13601 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -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', () => { @@ -18,4 +18,28 @@ Trailing text`; const raw = `{ "commits": [ { "title": 5 } ] }`; expect(() => extractJSON(raw)).toThrow(); }); -}); \ No newline at end of file +}); + +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; + } + }); +}); diff --git a/test/prompt.test.ts b/test/prompt.test.ts index 937548e..107f7b1 100644 --- a/test/prompt.test.ts +++ b/test/prompt.test.ts @@ -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\)/); + }); });