-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcodex-client.ts
More file actions
387 lines (347 loc) · 12.4 KB
/
Copy pathcodex-client.ts
File metadata and controls
387 lines (347 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
/**
* codex-client.ts — Codex worker behind one interface, three implementations.
*
* MockCodexClient — no network, deterministic; applies a scripted plan of
* file writes/deletes so the full loop can be proven.
* McpCodexClient — PRIMARY path: drives `codex mcp-server` over stdio using
* the official MCP SDK client.
* ExecCodexClient — FALLBACK only: shells out to `codex exec --json` and
* parses the JSONL event stream.
*
* All version-dependent Codex names (subcommands, tool names, flags) come from
* config.DEFAULT_CODEX_CLI so they can be reconciled in one place per Codex
* version. Live calls are only reachable when codexMode !== 'mock'.
*/
import { spawn } from 'node:child_process';
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import {
DEFAULT_CODEX_CLI,
type CodexCliConfig,
} from './config.js';
import { runCommand } from './verifier.js';
import type {
CodexClient,
CodexMode,
CodexTaskInput,
CodexTaskResult,
} from './types.js';
// =========================================================================
// Mock
// =========================================================================
export interface MockFileWrite {
path: string;
content: string;
}
export interface MockStep {
/** The "assistant" message Codex would return for this turn. */
message: string;
writeFiles?: MockFileWrite[];
deleteFiles?: string[];
}
export interface MockOptions {
/** Scripted steps; step N applied on the N-th runTask call. */
plan?: MockStep[];
/**
* If no inline plan is given, load steps from this JSON file (relative to
* projectDir) the first time runTask is called. Shape: { steps: MockStep[] }.
* Defaults to "mock-plan.json".
*/
planFile?: string;
/** Artificial per-call latency (used in tests to exercise the runtime limit). */
delayMs?: number;
}
export class MockCodexClient implements CodexClient {
readonly mode: CodexMode = 'mock';
private plan: MockStep[] | undefined;
private readonly planFile: string;
private readonly delayMs: number;
private callIndex = 0;
private readonly threadId = 'mock-thread-0001';
constructor(opts: MockOptions = {}) {
this.plan = opts.plan;
this.planFile = opts.planFile ?? 'mock-plan.json';
this.delayMs = opts.delayMs ?? 0;
}
async start(): Promise<void> {
/* nothing to start */
}
async stop(): Promise<void> {
/* nothing to stop */
}
private async ensurePlan(projectDir: string): Promise<MockStep[]> {
if (this.plan) return this.plan;
const file = path.resolve(projectDir, this.planFile);
try {
const raw = await fs.readFile(file, 'utf8');
const parsed = JSON.parse(raw) as { steps?: MockStep[] };
this.plan = Array.isArray(parsed.steps) ? parsed.steps : [];
} catch {
this.plan = [];
}
return this.plan;
}
async runTask(input: CodexTaskInput): Promise<CodexTaskResult> {
if (this.delayMs > 0) await new Promise((r) => setTimeout(r, this.delayMs));
const plan = await this.ensurePlan(input.projectDir);
const step = plan[this.callIndex];
this.callIndex++;
if (!step) {
return {
output:
'[mock] No further scripted changes. Reporting current state as final.',
threadId: this.threadId,
raw: { mock: true, callIndex: this.callIndex, noop: true },
};
}
const applied: string[] = [];
for (const w of step.writeFiles ?? []) {
const abs = path.resolve(input.projectDir, w.path);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, w.content, 'utf8');
applied.push(`wrote ${w.path}`);
}
for (const d of step.deleteFiles ?? []) {
const abs = path.resolve(input.projectDir, d);
try {
await fs.rm(abs, { recursive: false });
applied.push(`deleted ${d}`);
} catch {
applied.push(`could not delete ${d} (missing)`);
}
}
const output = `${step.message}\n\n[mock actions] ${applied.join('; ') || 'no file changes'}`;
return {
output,
threadId: this.threadId,
raw: { mock: true, callIndex: this.callIndex, applied },
};
}
}
// =========================================================================
// MCP server (primary)
// =========================================================================
/** Tolerantly pull a conversation/session/thread id out of any object tree. */
function findThreadId(value: unknown, depth = 0): string | undefined {
if (depth > 6 || value == null) return undefined;
if (typeof value === 'object') {
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (
typeof v === 'string' &&
/^(conversation|session|thread)_?id$/i.test(k) &&
v.length > 0
) {
return v;
}
}
for (const v of Object.values(value as Record<string, unknown>)) {
const found = findThreadId(v, depth + 1);
if (found) return found;
}
}
return undefined;
}
/**
* Extract assistant text from an MCP tool-call result. Codex puts the answer in
* `structuredContent.content` and mirrors it in the `content[]` text blocks.
*/
function extractText(result: unknown): string {
const r = result as {
structuredContent?: { content?: unknown };
content?: Array<{ type?: string; text?: string }>;
};
const sc = r?.structuredContent?.content;
if (typeof sc === 'string' && sc.trim()) return sc.trim();
if (Array.isArray(r?.content)) {
return r.content
.filter((c) => c?.type === 'text' && typeof c.text === 'string')
.map((c) => c.text as string)
.join('\n')
.trim();
}
return '';
}
/** Codex returns the session id in `structuredContent.threadId`. */
function extractThreadId(result: unknown): string | undefined {
const r = result as { structuredContent?: { threadId?: unknown } };
const t = r?.structuredContent?.threadId;
if (typeof t === 'string' && t.length > 0) return t;
return findThreadId(result);
}
export class McpCodexClient implements CodexClient {
readonly mode: CodexMode = 'mcp-server';
private client: Client | undefined;
private transport: StdioClientTransport | undefined;
private readonly cli: CodexCliConfig;
constructor(cli: CodexCliConfig = DEFAULT_CODEX_CLI) {
this.cli = cli;
}
async start(): Promise<void> {
this.transport = new StdioClientTransport({
command: this.cli.bin,
args: this.cli.mcpServerArgs,
stderr: 'inherit',
});
this.client = new Client(
{ name: 'codex-orchestrator', version: '0.1.0' },
{ capabilities: {} },
);
await this.client.connect(this.transport);
}
async stop(): Promise<void> {
try {
await this.client?.close();
} finally {
this.client = undefined;
this.transport = undefined;
}
}
async runTask(input: CodexTaskInput): Promise<CodexTaskResult> {
if (!this.client) {
throw new Error('McpCodexClient.start() must be called before runTask().');
}
const isContinuation = Boolean(input.threadId);
// IMPORTANT: the two tools have DIFFERENT, strict schemas.
// - `codex` : kebab-case, additionalProperties:false (unknown fields
// are rejected). Only prompt is required.
// - `codex-reply` : camelCase { threadId, prompt }; the session keeps the
// cwd/sandbox/approval it was started with.
const name = isContinuation ? this.cli.mcpReplyTool : this.cli.mcpStartTool;
const args: Record<string, unknown> = isContinuation
? { threadId: input.threadId, prompt: input.prompt }
: {
prompt: input.prompt,
cwd: path.resolve(input.projectDir),
sandbox: input.sandbox,
'approval-policy': input.approvalPolicy,
};
const result = (await this.client.callTool(
{ name, arguments: args },
undefined,
{ timeout: input.timeoutMs },
)) as unknown;
return {
output: extractText(result) || '[codex returned no text content]',
threadId: extractThreadId(result) ?? input.threadId,
raw: result,
};
}
}
// =========================================================================
// exec --json (fallback)
// =========================================================================
/**
* Parse a JSONL event stream from `codex exec --json`.
*
* Primary: the CURRENT ThreadEvent stream — dotted top-level `type`:
* {"type":"thread.started","thread_id":"<uuid>"}
* {"type":"item.completed","item":{"type":"agent_message","text":"<answer>"}}
* Note `reasoning` items also carry `text`, so we match `agent_message` exactly.
*
* Fallback: the LEGACY EventMsg stream (rollout/older versions) where payloads
* are wrapped under `msg` and the answer is `agent_message.message`.
*/
function parseExecJsonl(stdout: string): { text: string; threadId?: string } {
const lines = stdout.split('\n').filter((l) => l.trim().length > 0);
let threadId: string | undefined;
const agentMessages: string[] = []; // current-format final messages
let legacyLast = ''; // legacy fallback
for (const line of lines) {
let evt: any;
try {
evt = JSON.parse(line);
} catch {
continue; // non-JSON log line
}
const type: string = evt?.type ?? '';
// --- current ThreadEvent format ---
if (type === 'thread.started' && typeof evt.thread_id === 'string') {
threadId = evt.thread_id;
continue;
}
if (type === 'item.completed' && evt.item?.type === 'agent_message') {
if (typeof evt.item.text === 'string' && evt.item.text.trim()) {
agentMessages.push(evt.item.text);
}
continue;
}
// --- legacy EventMsg fallback ---
const payload = evt.msg ?? evt;
const ltype: string = payload.type ?? '';
if (/^session_configured$/.test(ltype)) {
threadId = threadId ?? payload.thread_id ?? payload.session_id;
} else if (/^agent_message$/.test(ltype)) {
const t = payload.message ?? payload.text ?? '';
if (typeof t === 'string' && t.trim()) legacyLast = t;
}
threadId = threadId ?? findThreadId(evt);
}
const text =
(agentMessages.join('\n').trim() || legacyLast.trim()) ||
'[no assistant text parsed from exec --json stream]';
return { text, threadId };
}
export class ExecCodexClient implements CodexClient {
readonly mode: CodexMode = 'exec';
private readonly cli: CodexCliConfig;
constructor(cli: CodexCliConfig = DEFAULT_CODEX_CLI) {
this.cli = cli;
}
async start(): Promise<void> {
/* exec spawns per task */
}
async stop(): Promise<void> {
/* nothing persistent */
}
async runTask(input: CodexTaskInput): Promise<CodexTaskResult> {
const cwd = path.resolve(input.projectDir);
// First turn: codex exec [--json -s <sandbox> -a <approval>] "<prompt>"
// Continuation: codex exec resume <id> [--json -s ... -a ...] "<prompt>"
// `resume` MUST come immediately after `exec`.
const parts = [this.cli.bin, ...this.cli.execArgs];
if (input.threadId) parts.push('resume', input.threadId);
parts.push(
this.cli.execJsonFlag,
'-s',
input.sandbox,
'-a',
input.approvalPolicy,
);
// Prompt passed via a single-quoted arg; runCommand uses a shell.
parts.push(shellQuote(input.prompt));
const command = parts.join(' ');
const res = await runCommand(command, cwd, input.timeoutMs);
if (res.error && !res.stdout) {
throw new Error(`codex exec failed: ${res.error}`);
}
const { text, threadId } = parseExecJsonl(res.stdout);
return {
output: text,
threadId: threadId ?? input.threadId,
raw: { stdout: res.stdout, stderr: res.stderr, exitCode: res.exitCode },
};
}
}
function shellQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
// =========================================================================
// Factory
// =========================================================================
export interface CodexClientOptions {
mode: CodexMode;
cli?: CodexCliConfig;
mock?: MockOptions;
}
export function createCodexClient(opts: CodexClientOptions): CodexClient {
switch (opts.mode) {
case 'mock':
return new MockCodexClient(opts.mock);
case 'mcp-server':
return new McpCodexClient(opts.cli);
case 'exec':
return new ExecCodexClient(opts.cli);
}
}