Capture file reads in Codex MCP timeline
Detect exec_command events with parsed_cmd.type === "read" and emit read_file timeline items instead of shell command items. This allows file reads (via cat, head, tail, etc.) to appear properly in the UI as file operations rather than generic shell commands. Changes: - Add ParsedCmdItemSchema for parsed_cmd array items - Add parsed_cmd field to ExecCommandBeginEventSchema and ExecCommandEndEventSchema - Add extractFileReadFromParsedCmd helper to detect file reads - Update exec_command_begin/end handlers to emit read_file timeline items - Update test prompt to allow cat-based file reads - Fix test assertion to find completed (not running) read_file calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
30af3ffd1a
commit
f96bde68b3
@@ -722,7 +722,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
"2. Run the command `bash -lc \"printf 'stderr-marker' 1>&2\"` using your shell tool.",
|
||||
"3. Use apply_patch (not the shell) to create a new file named tool-create.txt containing only the line 'alpha'.",
|
||||
"4. Use apply_patch (not the shell) to edit tool-create.txt, replacing 'alpha' with 'beta'.",
|
||||
"5. Use the read_file tool (not the shell) to read tool-create.txt.",
|
||||
"5. Read the file tool-create.txt and report its contents (you can use cat or any file reading method).",
|
||||
"6. Call the MCP tool test.echo with input {\"text\":\"mcp-ok\"}.",
|
||||
"7. Use the web_search tool to search for \"OpenAI Codex MCP\".",
|
||||
"8. Request approval to run the command `printf \"permit\" > tool-permission.txt`, then run it.",
|
||||
@@ -794,15 +794,12 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
|
||||
fileChangeCalls.some((item) => stringifyUnknown(item.output).includes("tool-create.txt"))
|
||||
).toBe(true);
|
||||
|
||||
// NOTE: Codex MCP does not expose a separate read_file tool.
|
||||
// Reading files is done via shell commands (cat/head/tail) instead.
|
||||
// The test prompt asks for read_file but Codex uses cat internally.
|
||||
const readCall = toolCalls.find((item) => item.tool === "read_file");
|
||||
// Skip assertion - Codex doesn't have a read_file tool
|
||||
if (readCall) {
|
||||
expect.soft(stringifyUnknown(readCall.input)).toContain("tool-create.txt");
|
||||
expect.soft(stringifyUnknown(readCall.output)).toContain("beta");
|
||||
}
|
||||
const readCall = toolCalls.find(
|
||||
(item) => item.tool === "read_file" && item.status === "completed"
|
||||
);
|
||||
expect.soft(readCall).toBeTruthy();
|
||||
expect.soft(stringifyUnknown(readCall?.input)).toContain("tool-create.txt");
|
||||
expect.soft(stringifyUnknown(readCall?.output)).toContain("beta");
|
||||
|
||||
const mcpCall = toolCalls.find(
|
||||
(item) => item.server === "test" && item.tool === "echo"
|
||||
|
||||
@@ -640,12 +640,23 @@ const CommandOutputObjectSchema = z
|
||||
};
|
||||
});
|
||||
|
||||
const ParsedCmdItemSchema = z.object({
|
||||
type: z.string(),
|
||||
cmd: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
});
|
||||
type ParsedCmdItem = z.infer<typeof ParsedCmdItemSchema>;
|
||||
|
||||
const ParsedCmdSchema = z.array(ParsedCmdItemSchema).optional();
|
||||
|
||||
const ExecCommandBeginEventSchema = z
|
||||
.object({
|
||||
type: z.literal("exec_command_begin"),
|
||||
call_id: CallIdSchema,
|
||||
command: CommandSchema,
|
||||
cwd: z.string().optional(),
|
||||
parsed_cmd: ParsedCmdSchema,
|
||||
})
|
||||
.passthrough()
|
||||
.transform((data) => ({
|
||||
@@ -653,6 +664,7 @@ const ExecCommandBeginEventSchema = z
|
||||
callId: data.call_id,
|
||||
command: data.command,
|
||||
cwd: data.cwd,
|
||||
parsedCmd: data.parsed_cmd,
|
||||
}));
|
||||
|
||||
const ExecCommandEndEventSchema = z
|
||||
@@ -669,6 +681,7 @@ const ExecCommandEndEventSchema = z
|
||||
status: z.string().optional(),
|
||||
success: z.boolean().optional(),
|
||||
error: z.unknown().optional(),
|
||||
parsed_cmd: ParsedCmdSchema,
|
||||
})
|
||||
.passthrough()
|
||||
.transform((data, ctx) => {
|
||||
@@ -698,6 +711,7 @@ const ExecCommandEndEventSchema = z
|
||||
status: data.status,
|
||||
success: data.success,
|
||||
error: data.error,
|
||||
parsedCmd: data.parsed_cmd,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1890,6 +1904,24 @@ function normalizeCommand(command: Command): string {
|
||||
return typeof command === "string" ? command : command.join(" ");
|
||||
}
|
||||
|
||||
function extractFileReadFromParsedCmd(parsedCmd: ParsedCmdItem[] | undefined): {
|
||||
path: string;
|
||||
name: string;
|
||||
} | null {
|
||||
if (!parsedCmd || parsedCmd.length === 0) {
|
||||
return null;
|
||||
}
|
||||
for (const item of parsedCmd) {
|
||||
if (item.type === "read" && item.path) {
|
||||
return {
|
||||
path: item.path,
|
||||
name: item.name ?? item.path.split("/").pop() ?? item.path,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildFileChangeSummary(files: { path: string; kind: string }[]): string {
|
||||
if (files.length === 1) {
|
||||
return `${files[0].kind}: ${files[0].path}`;
|
||||
@@ -3193,19 +3225,36 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
throw new Error("exec_command_begin missing call_id");
|
||||
}
|
||||
const commandText = normalizeCommand(parsedEvent.command);
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
status: "running",
|
||||
callId,
|
||||
displayName: commandText,
|
||||
kind: "execute",
|
||||
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
||||
}),
|
||||
});
|
||||
const fileRead = extractFileReadFromParsedCmd(parsedEvent.parsedCmd);
|
||||
if (fileRead) {
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "file",
|
||||
tool: "read_file",
|
||||
status: "running",
|
||||
callId,
|
||||
displayName: `Read: ${fileRead.name}`,
|
||||
kind: "read",
|
||||
input: { path: fileRead.path },
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
status: "running",
|
||||
callId,
|
||||
displayName: commandText,
|
||||
kind: "execute",
|
||||
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "exec_command_end": {
|
||||
@@ -3257,27 +3306,6 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
outputText = parsedEvent.stderr;
|
||||
}
|
||||
}
|
||||
let structuredOutput: unknown = outputRecord;
|
||||
if (outputText !== undefined || resolvedExitCode !== undefined) {
|
||||
const commandOutput: {
|
||||
type: "command";
|
||||
command: string;
|
||||
output?: string;
|
||||
exitCode?: number;
|
||||
cwd?: string;
|
||||
} = {
|
||||
type: "command",
|
||||
command: commandText,
|
||||
cwd: parsedEvent.cwd,
|
||||
};
|
||||
if (outputText !== undefined) {
|
||||
commandOutput.output = outputText;
|
||||
}
|
||||
if (resolvedExitCode !== undefined) {
|
||||
commandOutput.exitCode = resolvedExitCode;
|
||||
}
|
||||
structuredOutput = commandOutput;
|
||||
}
|
||||
const failed =
|
||||
parsedEvent.success === false ||
|
||||
parsedEvent.status === "failed" ||
|
||||
@@ -3286,20 +3314,63 @@ class CodexMcpAgentSession implements AgentSession {
|
||||
if (failed) {
|
||||
this.turnState && (this.turnState.sawError = true);
|
||||
}
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
status: failed ? "failed" : "completed",
|
||||
callId,
|
||||
displayName: commandText,
|
||||
kind: "execute",
|
||||
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
||||
output: structuredOutput,
|
||||
}),
|
||||
});
|
||||
const fileRead = extractFileReadFromParsedCmd(parsedEvent.parsedCmd);
|
||||
if (fileRead) {
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "file",
|
||||
tool: "read_file",
|
||||
status: failed ? "failed" : "completed",
|
||||
callId,
|
||||
displayName: `Read: ${fileRead.name}`,
|
||||
kind: "read",
|
||||
input: { path: fileRead.path },
|
||||
output: {
|
||||
type: "read_file",
|
||||
path: fileRead.path,
|
||||
content: outputText,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
let structuredOutput: unknown = outputRecord;
|
||||
if (outputText !== undefined || resolvedExitCode !== undefined) {
|
||||
const commandOutput: {
|
||||
type: "command";
|
||||
command: string;
|
||||
output?: string;
|
||||
exitCode?: number;
|
||||
cwd?: string;
|
||||
} = {
|
||||
type: "command",
|
||||
command: commandText,
|
||||
cwd: parsedEvent.cwd,
|
||||
};
|
||||
if (outputText !== undefined) {
|
||||
commandOutput.output = outputText;
|
||||
}
|
||||
if (resolvedExitCode !== undefined) {
|
||||
commandOutput.exitCode = resolvedExitCode;
|
||||
}
|
||||
structuredOutput = commandOutput;
|
||||
}
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
status: failed ? "failed" : "completed",
|
||||
callId,
|
||||
displayName: commandText,
|
||||
kind: "execute",
|
||||
input: { command: parsedEvent.command, cwd: parsedEvent.cwd },
|
||||
output: structuredOutput,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (failed) {
|
||||
const errorMessage =
|
||||
resolvedExitCode !== undefined
|
||||
|
||||
Reference in New Issue
Block a user