From 9a7904e9a6c2661d0f043792d027866fffadc169 Mon Sep 17 00:00:00 2001 From: Erwann Mest Date: Sun, 5 Jul 2026 12:44:50 +0100 Subject: [PATCH 1/2] feat(provider): add Codex CLI provider - Route codex/ models from createProvider to a new CodexCliProvider in src/model/provider.ts. - Add Codex CLI chat execution with schema-shaped output handling and mock provider support. - Cover codex/ routing and mock-mode JSON parsing in test/model.test.ts. --- src/model/provider.ts | 117 +++++++++++++++++++++++++++++++++++++++++- test/model.test.ts | 28 +++++++++- 2 files changed, 141 insertions(+), 4 deletions(-) diff --git a/src/model/provider.ts b/src/model/provider.ts index a40cae7..071cfd8 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-codex).`, ); } @@ -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,118 @@ 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. + // `--output-schema` pins the final message to the commit-plan shape and + // `--output-last-message` writes only that message to a file, so we read the + // answer from disk rather than scraping the trace. read-only + ephemeral keep + // it a pure completion that never writes to the repo or persists a session. + const dir = join(tmpdir(), `codex-aicc-${process.pid}`); + mkdirSync(dir, { recursive: true }); + const schemaPath = join(dir, 'schema.json'); + const outPath = join(dir, 'last-message.txt'); + writeFileSync(schemaPath, JSON.stringify(COMMIT_PLAN_JSON_SCHEMA)); + + const args = [ + 'exec', + '--model', + this.modelAlias, + '--sandbox', + 'read-only', + '--skip-git-repo-check', + '--ephemeral', + '--color', + 'never', + '--output-schema', + schemaPath, + '--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/test/model.test.ts b/test/model.test.ts index 4f31b4b..8f4723a 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-codex'); + 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-codex').chat([ + { role: 'user', content: 'diff' }, + ]); + const plan = extractJSON(raw); + expect(plan.commits.length).toBeGreaterThan(0); + } finally { + process.env.AICC_DEBUG_PROVIDER = previous; + } + }); +}); From d43d99207b97e8078892c021f6a6725768abe5ad Mon Sep 17 00:00:00 2001 From: Erwann Mest Date: Sun, 5 Jul 2026 12:49:58 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix(codex-provider):=20remov?= =?UTF-8?q?e=20--output-schema,=20rely=20on=20prompt-based=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop `--output-schema` / `schemaPath` from `CodexCliProvider.chat()`: OpenAI strict structured outputs reject our shared schema because it lacks `additionalProperties:false` on every nested object - Rely on the system prompt's embedded schema + "Return ONLY the JSON object" instruction, mirroring how the `claude` provider works, and let `extractJSON` parse the result - Update error-message example model from `codex/gpt-5-codex` to `codex/gpt-5.5` in `validateModel` and align tests accordingly - Add `Body Rules (REQUIRED for non-trivial changes)` spec line to `buildGenerationMessages` so the LLM always populates the body for feat/fix/refactor/multi-file diffs - Minor formatting: collapse single-line arrow functions and reflow long `dbg()` call in `prompt.ts` --- src/model/provider.ts | 19 +++++++++---------- src/prompt.ts | 16 ++++++++++++---- test/model.test.ts | 4 ++-- test/prompt.test.ts | 9 +++++++++ 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/model/provider.ts b/src/model/provider.ts index 071cfd8..bbd7e9a 100644 --- a/src/model/provider.ts +++ b/src/model/provider.ts @@ -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, codex/gpt-5-codex).`, + `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).`, ); } @@ -483,16 +483,17 @@ export class CodexCliProvider implements Provider { 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. - // `--output-schema` pins the final message to the commit-plan shape and - // `--output-last-message` writes only that message to a file, so we read the - // answer from disk rather than scraping the trace. read-only + ephemeral keep - // it a pure completion that never writes to the repo or persists a session. + // `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 schemaPath = join(dir, 'schema.json'); const outPath = join(dir, 'last-message.txt'); - writeFileSync(schemaPath, JSON.stringify(COMMIT_PLAN_JSON_SCHEMA)); const args = [ 'exec', @@ -504,8 +505,6 @@ export class CodexCliProvider implements Provider { '--ephemeral', '--color', 'never', - '--output-schema', - schemaPath, '--output-last-message', outPath, '-', // read the prompt from stdin 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 8f4723a..5d13601 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -22,7 +22,7 @@ Trailing text`; describe('createProvider', () => { it('routes codex/ models to the Codex CLI provider', () => { - const provider = createProvider('codex/gpt-5-codex'); + const provider = createProvider('codex/gpt-5.5'); expect(provider).toBeInstanceOf(CodexCliProvider); expect(provider.name()).toBe('codex-cli'); }); @@ -33,7 +33,7 @@ describe('CodexCliProvider', () => { const previous = process.env.AICC_DEBUG_PROVIDER; process.env.AICC_DEBUG_PROVIDER = 'mock'; try { - const raw = await new CodexCliProvider('codex/gpt-5-codex').chat([ + const raw = await new CodexCliProvider('codex/gpt-5.5').chat([ { role: 'user', content: 'diff' }, ]); const plan = extractJSON(raw); 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\)/); + }); });