codext対応
This commit is contained in:
parent
90cac7e25e
commit
351327a779
@ -35,6 +35,7 @@ export type {
|
||||
InitEventData,
|
||||
ToolUseEventData,
|
||||
ToolResultEventData,
|
||||
ToolOutputEventData,
|
||||
TextEventData,
|
||||
ThinkingEventData,
|
||||
ResultEventData,
|
||||
|
||||
@ -31,6 +31,7 @@ export type {
|
||||
InitEventData,
|
||||
ToolUseEventData,
|
||||
ToolResultEventData,
|
||||
ToolOutputEventData,
|
||||
TextEventData,
|
||||
ThinkingEventData,
|
||||
ResultEventData,
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user