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:
Mohamed Boudra
2025-12-25 15:08:13 +07:00
co-authored by Claude Opus 4.5
parent 30af3ffd1a
commit f96bde68b3
3 changed files with 128 additions and 59 deletions
@@ -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