codext対応

This commit is contained in:
nrslib 2026-01-26 17:39:09 +09:00
parent 90cac7e25e
commit 351327a779
5 changed files with 415 additions and 50 deletions

View File

@ -35,6 +35,7 @@ export type {
InitEventData,
ToolUseEventData,
ToolResultEventData,
ToolOutputEventData,
TextEventData,
ThinkingEventData,
ResultEventData,

View File

@ -31,6 +31,7 @@ export type {
InitEventData,
ToolUseEventData,
ToolResultEventData,
ToolOutputEventData,
TextEventData,
ThinkingEventData,
ResultEventData,

View File

@ -27,6 +27,11 @@ export interface ToolResultEventData {
isError: boolean;
}
export interface ToolOutputEventData {
tool: string;
output: string;
}
export interface TextEventData {
text: string;
}
@ -51,6 +56,7 @@ export type StreamEvent =
| { type: 'init'; data: InitEventData }
| { type: 'tool_use'; data: ToolUseEventData }
| { type: 'tool_result'; data: ToolResultEventData }
| { type: 'tool_output'; data: ToolOutputEventData }
| { type: 'text'; data: TextEventData }
| { type: 'thinking'; data: ThinkingEventData }
| { type: 'result'; data: ResultEventData }

View File

@ -31,43 +31,6 @@ function extractThreadId(value: unknown): string | undefined {
return typeof id === 'string' ? id : undefined;
}
function normalizeCodexResult(result: unknown): string {
if (result == null) return '';
if (typeof result === 'string') return result;
if (typeof result !== 'object') return String(result);
const record = result as Record<string, unknown>;
const directFields = ['output_text', 'output', 'content', 'text', 'message'];
for (const field of directFields) {
const value = record[field];
if (typeof value === 'string') {
return value;
}
}
if (Array.isArray(record.output)) {
const first = record.output[0];
if (typeof first === 'string') return first;
if (first && typeof first === 'object') {
const text = (first as Record<string, unknown>).text;
if (typeof text === 'string') return text;
}
}
if (Array.isArray(record.choices)) {
const firstChoice = record.choices[0] as Record<string, unknown> | undefined;
const message = firstChoice?.message as Record<string, unknown> | undefined;
const content = message?.content;
if (typeof content === 'string') return content;
}
try {
return JSON.stringify(result, null, 2);
} catch {
return String(result);
}
}
function emitInit(
onStream: StreamCallback | undefined,
model: string | undefined,
@ -88,6 +51,39 @@ function emitText(onStream: StreamCallback | undefined, text: string): void {
onStream({ type: 'text', data: { text } });
}
function emitThinking(onStream: StreamCallback | undefined, thinking: string): void {
if (!onStream || !thinking) return;
onStream({ type: 'thinking', data: { thinking } });
}
function emitToolUse(
onStream: StreamCallback | undefined,
tool: string,
input: Record<string, unknown>,
id: string
): void {
if (!onStream) return;
onStream({ type: 'tool_use', data: { tool, input, id } });
}
function emitToolResult(
onStream: StreamCallback | undefined,
content: string,
isError: boolean
): void {
if (!onStream) return;
onStream({ type: 'tool_result', data: { content, isError } });
}
function emitToolOutput(
onStream: StreamCallback | undefined,
tool: string,
output: string
): void {
if (!onStream || !output) return;
onStream({ type: 'tool_output', data: { tool, output } });
}
function emitResult(
onStream: StreamCallback | undefined,
success: boolean,
@ -109,6 +105,237 @@ function determineStatus(content: string, patterns: Record<string, string>): Sta
return detectStatus(content, patterns);
}
type CodexEvent = {
type: string;
[key: string]: unknown;
};
type CodexItem = {
id?: string;
type: string;
[key: string]: unknown;
};
function formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string {
if (!changes.length) return '';
return changes
.map((change) => {
const kind = change.kind ? `${change.kind}: ` : '';
return `${kind}${change.path ?? ''}`.trim();
})
.filter(Boolean)
.join('\n');
}
function emitCodexItemStart(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
if (startedItems.has(id)) return;
switch (item.type) {
case 'command_execution': {
const command = typeof item.command === 'string' ? item.command : '';
emitToolUse(onStream, 'Bash', { command }, id);
startedItems.add(id);
break;
}
case 'mcp_tool_call': {
const tool = typeof item.tool === 'string' ? item.tool : 'Tool';
const args = (item.arguments ?? {}) as Record<string, unknown>;
emitToolUse(onStream, tool, args, id);
startedItems.add(id);
break;
}
case 'web_search': {
const query = typeof item.query === 'string' ? item.query : '';
emitToolUse(onStream, 'WebSearch', { query }, id);
startedItems.add(id);
break;
}
case 'file_change': {
const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id);
startedItems.add(id);
break;
}
default:
break;
}
}
function emitCodexItemCompleted(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
emitThinking(onStream, text.slice(prev) + '\n');
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'command_execution': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0);
const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : '');
emitToolResult(onStream, content, isError);
break;
}
case 'mcp_tool_call': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed' || !!item.error;
const errorMessage =
item.error && typeof item.error === 'object' && 'message' in item.error
? String((item.error as { message?: unknown }).message ?? '')
: '';
let content = errorMessage;
if (!content && item.result && typeof item.result === 'object') {
try {
content = JSON.stringify(item.result);
} catch {
content = '';
}
}
emitToolResult(onStream, content, isError);
break;
}
case 'web_search': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
emitToolResult(onStream, 'Search completed', false);
break;
}
case 'file_change': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const status = typeof item.status === 'string' ? item.status : '';
const isError = status === 'failed';
const changes = Array.isArray(item.changes) ? item.changes : [];
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
emitToolResult(onStream, summary || 'Applied patch', isError);
break;
}
default:
break;
}
}
function emitCodexItemUpdate(
item: CodexItem,
onStream: StreamCallback | undefined,
startedItems: Set<string>,
outputOffsets: Map<string, number>,
textOffsets: Map<string, number>,
thinkingOffsets: Map<string, number>
): void {
if (!onStream) return;
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
switch (item.type) {
case 'command_execution': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
if (output) {
const prev = outputOffsets.get(id) ?? 0;
if (output.length > prev) {
emitToolOutput(onStream, 'Bash', output.slice(prev));
outputOffsets.set(id, output.length);
}
}
break;
}
case 'agent_message': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = textOffsets.get(id) ?? 0;
if (text.length > prev) {
emitText(onStream, text.slice(prev));
textOffsets.set(id, text.length);
}
}
break;
}
case 'reasoning': {
const text = typeof item.text === 'string' ? item.text : '';
if (text) {
const prev = thinkingOffsets.get(id) ?? 0;
if (text.length > prev) {
emitThinking(onStream, text.slice(prev));
thinkingOffsets.set(id, text.length);
}
}
break;
}
case 'file_change': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
case 'mcp_tool_call': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
case 'web_search': {
if (!startedItems.has(id)) {
emitCodexItemStart(item, onStream, startedItems);
}
break;
}
default:
break;
}
}
/**
* Call Codex with an agent prompt.
*/
@ -118,17 +345,19 @@ export async function callCodex(
options: CodexCallOptions
): Promise<AgentResponse> {
const codex = new Codex();
const threadOptions = {
model: options.model,
workingDirectory: options.cwd,
};
const thread = options.sessionId
? await codex.resumeThread(options.sessionId)
: await codex.startThread();
const threadId = extractThreadId(thread) || options.sessionId;
? await codex.resumeThread(options.sessionId, threadOptions)
: await codex.startThread(threadOptions);
let threadId = extractThreadId(thread) || options.sessionId;
const fullPrompt = options.systemPrompt
? `${options.systemPrompt}\n\n${prompt}`
: prompt;
emitInit(options.onStream, options.model, threadId);
try {
log.debug('Executing Codex thread', {
agentType,
@ -136,21 +365,93 @@ export async function callCodex(
hasSystemPrompt: !!options.systemPrompt,
});
const runOptions = options.model ? { model: options.model } : undefined;
const result = await (thread as { run: (p: string, o?: unknown) => Promise<unknown> })
.run(fullPrompt, runOptions);
const { events } = await thread.runStreamed(fullPrompt);
let content = '';
let success = true;
let failureMessage = '';
const startedItems = new Set<string>();
const outputOffsets = new Map<string, number>();
const textOffsets = new Map<string, number>();
const thinkingOffsets = new Map<string, number>();
const content = normalizeCodexResult(result).trim();
emitText(options.onStream, content);
emitResult(options.onStream, true, content, threadId);
for await (const event of events as AsyncGenerator<CodexEvent>) {
if (event.type === 'thread.started') {
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
emitInit(options.onStream, options.model, threadId);
continue;
}
if (event.type === 'turn.failed') {
success = false;
if (event.error && typeof event.error === 'object' && 'message' in event.error) {
failureMessage = String((event.error as { message?: unknown }).message ?? '');
}
break;
}
if (event.type === 'error') {
success = false;
failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error';
break;
}
if (event.type === 'item.started') {
const item = event.item as CodexItem | undefined;
if (item) {
emitCodexItemStart(item, options.onStream, startedItems);
}
continue;
}
if (event.type === 'item.updated') {
const item = event.item as CodexItem | undefined;
if (item) {
emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets);
}
continue;
}
if (event.type === 'item.completed') {
const item = event.item as CodexItem | undefined;
if (item) {
if (item.type === 'agent_message' && typeof item.text === 'string') {
content = item.text;
}
emitCodexItemCompleted(
item,
options.onStream,
startedItems,
outputOffsets,
textOffsets,
thinkingOffsets
);
}
continue;
}
}
if (!success) {
const message = failureMessage || 'Codex execution failed';
emitResult(options.onStream, false, message, threadId);
return {
agent: agentType,
status: 'blocked',
content: message,
timestamp: new Date(),
sessionId: threadId,
};
}
const trimmed = content.trim();
emitResult(options.onStream, true, trimmed, threadId);
const patterns = options.statusPatterns || GENERIC_STATUS_PATTERNS;
const status = determineStatus(content, patterns);
const status = determineStatus(trimmed, patterns);
return {
agent: agentType,
status,
content,
content: trimmed,
timestamp: new Date(),
sessionId: threadId,
};

View File

@ -151,6 +151,9 @@ export function truncate(text: string, maxLength: number): string {
/** Stream display manager for real-time Claude output */
export class StreamDisplay {
private lastToolUse: string | null = null;
private currentToolInputPreview: string | null = null;
private toolOutputBuffer = '';
private toolOutputPrinted = false;
private textBuffer = '';
private thinkingBuffer = '';
private isFirstText = true;
@ -206,6 +209,31 @@ export class StreamDisplay {
// Start spinner to show tool is executing
this.startToolSpinner(tool, inputPreview);
this.lastToolUse = tool;
this.currentToolInputPreview = inputPreview;
this.toolOutputBuffer = '';
this.toolOutputPrinted = false;
}
/** Display tool output streaming */
showToolOutput(output: string, tool?: string): void {
if (!output) return;
this.stopToolSpinner();
this.flushThinking();
this.flushText();
if (tool && !this.lastToolUse) {
this.lastToolUse = tool;
}
this.toolOutputBuffer += output;
const lines = this.toolOutputBuffer.split(/\r?\n/);
this.toolOutputBuffer = lines.pop() ?? '';
this.printToolOutputLines(lines, tool);
if (this.lastToolUse && this.currentToolInputPreview) {
this.startToolSpinner(this.lastToolUse, this.currentToolInputPreview);
}
}
/** Display tool result event */
@ -213,6 +241,11 @@ export class StreamDisplay {
// Stop the spinner first
this.stopToolSpinner();
if (this.toolOutputBuffer) {
this.printToolOutputLines([this.toolOutputBuffer], this.lastToolUse ?? undefined);
this.toolOutputBuffer = '';
}
const toolName = this.lastToolUse || 'Tool';
if (isError) {
const errorContent = content || 'Unknown error';
@ -225,6 +258,8 @@ export class StreamDisplay {
console.log(chalk.green(`${toolName}`));
}
this.lastToolUse = null;
this.currentToolInputPreview = null;
this.toolOutputPrinted = false;
}
/** Display streaming thinking (Claude's internal reasoning) */
@ -308,6 +343,9 @@ export class StreamDisplay {
reset(): void {
this.stopToolSpinner();
this.lastToolUse = null;
this.currentToolInputPreview = null;
this.toolOutputBuffer = '';
this.toolOutputPrinted = false;
this.textBuffer = '';
this.thinkingBuffer = '';
this.isFirstText = true;
@ -330,6 +368,9 @@ export class StreamDisplay {
case 'tool_result':
this.showToolResult(event.data.content, event.data.isError);
break;
case 'tool_output':
this.showToolOutput(event.data.output, event.data.tool);
break;
case 'text':
this.showText(event.data.text);
break;
@ -372,4 +413,19 @@ export class StreamDisplay {
}
}
}
private ensureToolOutputHeader(tool?: string): void {
if (this.toolOutputPrinted) return;
const label = tool || this.lastToolUse || 'Tool';
console.log(chalk.gray(` ${chalk.yellow(label)} output:`));
this.toolOutputPrinted = true;
}
private printToolOutputLines(lines: string[], tool?: string): void {
if (lines.length === 0) return;
this.ensureToolOutputHeader(tool);
for (const line of lines) {
console.log(chalk.gray(`${line}`));
}
}
}