Show every Codex terminal command in agent timelines (#2037)

* fix(codex): surface silent terminal activity

Successful no-output commands use a null output field, while terminal interactions carry their stdin separately. Preserve both so the agent timeline remains auditable.

* refactor(tests): share Codex event waiter

* fix(codex): preserve bounded terminal audit details

Keep terminal stdin through late command relabeling, cap it with other timeline content, and accept nullable output from legacy live completion events.

* fix(codex): preserve repeated terminal writes
This commit is contained in:
Mohamed Boudra
2026-07-13 15:37:28 +02:00
committed by GitHub
parent 13d6ad598e
commit fe9a486ac1
6 changed files with 393 additions and 25 deletions
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
describe("agent timeline content", () => {
test("limits terminal input to the tool-call content budget", () => {
const oversizedInput = "x".repeat(64 * 1024 + 1);
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId: "terminal-session-4242",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: oversizedInput,
icon: "square_terminal",
},
});
expect(item).toEqual({
type: "tool_call",
callId: "terminal-session-4242",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "x".repeat(64 * 1024),
icon: "square_terminal",
},
});
});
});
@@ -24,8 +24,27 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
}; };
} }
function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
if (
item.type !== "tool_call" ||
item.detail.type !== "plain_text" ||
typeof item.detail.text !== "string" ||
item.detail.text.length <= TOOL_CALL_CONTENT_MAX_LENGTH
) {
return item;
}
return {
...item,
detail: {
...item.detail,
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
}
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem { export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
item = limitFailedShellError(item); item = limitFailedShellError(item);
item = limitPlainText(item);
if ( if (
item.type !== "tool_call" || item.type !== "tool_call" ||
item.detail.type !== "shell" || item.detail.type !== "shell" ||
@@ -31,6 +31,8 @@ import {
createFakeCodexAppServer, createFakeCodexAppServer,
type FakeCodexAppServer, type FakeCodexAppServer,
waitForNextPermission, waitForNextPermission,
waitForNextTimelineItem,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js"; } from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js"; import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
@@ -554,6 +556,215 @@ describe("Codex app-server provider", () => {
await session.close(); await session.close();
}); });
test("shows a successful shell command that produces no output", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.completesSilentCommand({
threadId: "thread-1",
callId: "silent-merge",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
});
appServer.says({ threadId: "thread-1", text: "Merged." });
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "silent-merge",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
exitCode: 0,
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("shows a silent shell command from legacy live notifications", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.completesSilentLegacyCommand({
threadId: "thread-1",
callId: "legacy-silent-merge",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
});
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "legacy-silent-merge",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "gh pr merge 2030 --squash",
cwd: "/workspace/project",
exitCode: 0,
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("shows the exact bytes Codex writes into an existing terminal", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const nextTimelineItem = waitForNextTimelineItem(session);
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "4242",
text: "gh pr merge 2030 --squash\n",
});
await expect(nextTimelineItem).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "terminal-session-4242-1",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "gh pr merge 2030 --squash\n",
icon: "square_terminal",
},
metadata: {
processId: "4242",
},
},
});
const relabeledTerminal = waitForTimelineToolCall(session, "terminal-session-4242-1");
appServer.runsLegacyCommand({
threadId: "thread-1",
callId: "interactive-shell",
command: "sleep 30",
output: "Process running with session id 4242",
});
await expect(relabeledTerminal).resolves.toEqual({
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "terminal-session-4242-1",
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
label: "sleep 30",
text: "gh pr merge 2030 --squash\n",
icon: "square_terminal",
},
metadata: {
processId: "4242",
},
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("keeps repeated writes to one terminal as separate timeline rows", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const firstTimelineItem = waitForNextTimelineItem(session);
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "4242",
text: "git status\n",
});
const secondTimelineItem = waitForNextTimelineItem(session);
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "4242",
text: "git push\n",
});
const [first, second] = await Promise.all([firstTimelineItem, secondTimelineItem]);
expect(first.item).toMatchObject({
type: "tool_call",
callId: "terminal-session-4242-1",
detail: { type: "plain_text", text: "git status\n" },
});
expect(second.item).toMatchObject({
type: "tool_call",
callId: "terminal-session-4242-2",
detail: { type: "plain_text", text: "git push\n" },
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("surfaces an MCP elicitation and returns Codex's required approval action", async () => { test("surfaces an MCP elicitation and returns Codex's required approval action", async () => {
const appServer = createFakeCodexAppServer(); const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession( const session = new CodexAppServerAgentSession(
@@ -1514,24 +1514,23 @@ export function mapCodexPatchNotificationToToolCall(params: {
} }
function mapCodexTerminalInteractionToToolCall(params: { function mapCodexTerminalInteractionToToolCall(params: {
callId: string;
processId?: string | null; processId?: string | null;
fallbackCallId?: string | null;
command?: string | null; command?: string | null;
stdin?: string | null;
}): ToolCallTimelineItem { }): ToolCallTimelineItem {
const processId = nonEmptyString(params.processId ?? undefined); const processId = nonEmptyString(params.processId ?? undefined);
const callId = processId
? `terminal-session-${processId}`
: (nonEmptyString(params.fallbackCallId ?? undefined) ?? "terminal-interaction");
const label = nonEmptyString(params.command ?? undefined); const label = nonEmptyString(params.command ?? undefined);
return { return {
type: "tool_call", type: "tool_call",
callId, callId: params.callId,
name: "terminal", name: "terminal",
status: "completed", status: "completed",
error: null, error: null,
detail: { detail: {
type: "plain_text", type: "plain_text",
...(label ? { label } : {}), ...(label ? { label } : {}),
...(params.stdin !== null && params.stdin !== undefined ? { text: params.stdin } : {}),
icon: "square_terminal", icon: "square_terminal",
}, },
...(processId ? { metadata: { processId } } : {}), ...(processId ? { metadata: { processId } } : {}),
@@ -2111,8 +2110,8 @@ const CodexEventExecCommandEndNotificationSchema = z
cwd: z.string().optional(), cwd: z.string().optional(),
stdout: z.string().optional(), stdout: z.string().optional(),
stderr: z.string().optional(), stderr: z.string().optional(),
aggregated_output: z.string().optional(), aggregated_output: z.string().nullable().optional(),
aggregatedOutput: z.string().optional(), aggregatedOutput: z.string().nullable().optional(),
formatted_output: z.string().optional(), formatted_output: z.string().optional(),
exit_code: z.number().nullable().optional(), exit_code: z.number().nullable().optional(),
exitCode: z.number().nullable().optional(), exitCode: z.number().nullable().optional(),
@@ -3102,7 +3101,11 @@ export class CodexAppServerAgentSession implements AgentSession {
private pendingFileChangeOutputDeltas = new Map<string, string[]>(); private pendingFileChangeOutputDeltas = new Map<string, string[]>();
private pendingAssistantMessageBoundary = false; private pendingAssistantMessageBoundary = false;
private terminalCommandByProcessId = new Map<string, string>(); private terminalCommandByProcessId = new Map<string, string>();
private pendingUnlabeledTerminalInteractions = new Set<string>(); private pendingUnlabeledTerminalInteractions = new Map<
string,
Array<{ callId: string; stdin: string | null }>
>();
private nextTerminalInteractionOrdinal = 0;
private emittedTerminalInteractionKeys = new Set<string>(); private emittedTerminalInteractionKeys = new Set<string>();
private emittedExecCommandStartedCallIds = new Set<string>(); private emittedExecCommandStartedCallIds = new Set<string>();
private emittedExecCommandCompletedCallIds = new Set<string>(); private emittedExecCommandCompletedCallIds = new Set<string>();
@@ -5447,13 +5450,18 @@ export class CodexAppServerAgentSession implements AgentSession {
const command = const command =
(parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ?? (parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ??
null; null;
const callId = this.createTerminalInteractionCallId(parsed.processId, parsed.callId);
if (!command && parsed.processId) { if (!command && parsed.processId) {
this.pendingUnlabeledTerminalInteractions.add(parsed.processId); const pendingInteractions =
this.pendingUnlabeledTerminalInteractions.get(parsed.processId) ?? [];
pendingInteractions.push({ callId, stdin: parsed.stdin });
this.pendingUnlabeledTerminalInteractions.set(parsed.processId, pendingInteractions);
} }
const timelineItem = mapCodexTerminalInteractionToToolCall({ const timelineItem = mapCodexTerminalInteractionToToolCall({
callId,
processId: parsed.processId, processId: parsed.processId,
fallbackCallId: parsed.callId,
command, command,
stdin: parsed.stdin,
}); });
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
} }
@@ -5862,15 +5870,31 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.pendingUnlabeledTerminalInteractions.has(processId)) { if (!this.pendingUnlabeledTerminalInteractions.has(processId)) {
return; return;
} }
const pendingInteractions = this.pendingUnlabeledTerminalInteractions.get(processId) ?? [];
this.pendingUnlabeledTerminalInteractions.delete(processId); this.pendingUnlabeledTerminalInteractions.delete(processId);
this.emitEvent({ for (const pendingInteraction of pendingInteractions) {
type: "timeline", this.emitEvent({
provider: CODEX_PROVIDER, type: "timeline",
item: mapCodexTerminalInteractionToToolCall({ provider: CODEX_PROVIDER,
processId, item: mapCodexTerminalInteractionToToolCall({
command: displayCommand, callId: pendingInteraction.callId,
}), processId,
}); command: displayCommand,
stdin: pendingInteraction.stdin,
}),
});
}
}
private createTerminalInteractionCallId(
processId: string | null,
fallbackCallId: string | null,
): string {
const baseCallId = processId
? `terminal-session-${processId}`
: (nonEmptyString(fallbackCallId ?? undefined) ?? "terminal-interaction");
this.nextTerminalInteractionOrdinal += 1;
return `${baseCallId}-${this.nextTerminalInteractionOrdinal}`;
} }
private shouldEmitTerminalInteractionKey(key: string): boolean { private shouldEmitTerminalInteractionKey(key: string): boolean {
@@ -19,6 +19,19 @@ interface FakeLegacyCommand {
command: string; command: string;
output: string; output: string;
} }
interface FakeSilentCommand {
threadId: string;
callId: string;
command: string;
cwd: string;
}
interface FakeTerminalInput {
threadId: string;
turnId: string;
itemId: string;
processId: string;
text: string;
}
interface FakeLegacyPatch { interface FakeLegacyPatch {
threadId: string; threadId: string;
callId: string; callId: string;
@@ -51,6 +64,9 @@ export interface FakeCodexAppServer {
runsLegacyCommand(params: FakeLegacyCommand): void; runsLegacyCommand(params: FakeLegacyCommand): void;
appliesLegacyPatch(params: FakeLegacyPatch): void; appliesLegacyPatch(params: FakeLegacyPatch): void;
completesCommand(params: FakeLegacyCommand): void; completesCommand(params: FakeLegacyCommand): void;
completesSilentCommand(params: FakeSilentCommand): void;
completesSilentLegacyCommand(params: FakeSilentCommand): void;
typesIntoTerminal(params: FakeTerminalInput): void;
says(params: { threadId: string; itemId?: string; text: string; chunks?: string[] }): void; says(params: { threadId: string; itemId?: string; text: string; chunks?: string[] }): void;
requestCommandApproval(params: { requestCommandApproval(params: {
itemId: string; itemId: string;
@@ -366,6 +382,37 @@ export function createFakeCodexAppServer(
exitCode: 0, exitCode: 0,
}); });
}, },
completesSilentCommand(params) {
completeItem(params.threadId, {
type: "commandExecution",
id: params.callId,
status: "completed",
command: params.command,
cwd: params.cwd,
aggregatedOutput: null,
exitCode: 0,
});
},
completesSilentLegacyCommand(params) {
writeLegacyEvent(params.threadId, "codex/event/exec_command_end", {
type: "exec_command_end",
call_id: params.callId,
command: params.command,
cwd: params.cwd,
aggregatedOutput: null,
exit_code: 0,
success: true,
});
},
typesIntoTerminal(params) {
writeNotification("item/commandExecution/terminalInteraction", {
threadId: params.threadId,
turnId: params.turnId,
itemId: params.itemId,
processId: params.processId,
stdin: params.text,
});
},
says(params) { says(params) {
if (params.itemId) { if (params.itemId) {
for (const chunk of params.chunks ?? [params.text]) { for (const chunk of params.chunks ?? [params.text]) {
@@ -458,21 +505,53 @@ function toJsonObject(value: unknown): JsonObject {
return {}; return {};
} }
export function waitForNextPermission( type StreamEventType = AgentStreamEvent["type"];
type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent, { type: TType }>;
function waitForNextEvent<TType extends StreamEventType>(
session: AgentSession, session: AgentSession,
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> { type: TType,
accepts?: (event: StreamEventOfType<TType>) => boolean,
): Promise<StreamEventOfType<TType>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
unsubscribe(); unsubscribe();
reject(new Error("Timed out waiting for permission_requested")); reject(new Error(`Timed out waiting for ${type}`));
}, 1000); }, 1000);
const unsubscribe = session.subscribe((event) => { const unsubscribe = session.subscribe((event) => {
if (event.type !== "permission_requested") { if (event.type !== type) {
return;
}
const typedEvent = event as StreamEventOfType<TType>;
if (accepts && !accepts(typedEvent)) {
return; return;
} }
clearTimeout(timeout); clearTimeout(timeout);
unsubscribe(); unsubscribe();
resolve(event); resolve(typedEvent);
}); });
}); });
} }
type TimelineEvent = StreamEventOfType<"timeline">;
export function waitForNextPermission(
session: AgentSession,
): Promise<StreamEventOfType<"permission_requested">> {
return waitForNextEvent(session, "permission_requested");
}
export function waitForNextTimelineItem(session: AgentSession): Promise<TimelineEvent> {
return waitForNextEvent(session, "timeline");
}
export function waitForTimelineToolCall(
session: AgentSession,
callId: string,
): Promise<TimelineEvent> {
return waitForNextEvent(
session,
"timeline",
(event) => event.item.type === "tool_call" && event.item.callId === callId,
);
}
@@ -141,7 +141,7 @@ const CodexCommandExecutionItemSchema = z
error: z.unknown().optional(), error: z.unknown().optional(),
command: CodexCommandValueSchema.optional(), command: CodexCommandValueSchema.optional(),
cwd: z.string().optional(), cwd: z.string().optional(),
aggregatedOutput: z.string().optional(), aggregatedOutput: z.string().nullable().optional(),
exitCode: z.number().nullable().optional(), exitCode: z.number().nullable().optional(),
}) })
.passthrough(); .passthrough();
@@ -694,7 +694,7 @@ function mapCommandExecutionItem(
item: z.infer<typeof CodexCommandExecutionItemSchema>, item: z.infer<typeof CodexCommandExecutionItemSchema>,
): CodexNormalizedToolCallEnvelope { ): CodexNormalizedToolCallEnvelope {
const command = normalizeCommandExecutionCommand(item.command); const command = normalizeCommandExecutionCommand(item.command);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput); const parsedOutput = extractCodexShellOutput(item.aggregatedOutput ?? undefined);
const input = toNullableObject({ const input = toNullableObject({
...(command !== undefined ? { command } : {}), ...(command !== undefined ? { command } : {}),
...(item.cwd !== undefined ? { cwd: item.cwd } : {}), ...(item.cwd !== undefined ? { cwd: item.cwd } : {}),