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

View File

@@ -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",
},
});
});
});

View File

@@ -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 {
item = limitFailedShellError(item);
item = limitPlainText(item);
if (
item.type !== "tool_call" ||
item.detail.type !== "shell" ||

View File

@@ -31,6 +31,8 @@ import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextPermission,
waitForNextTimelineItem,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
@@ -554,6 +556,215 @@ describe("Codex app-server provider", () => {
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 () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(

View File

@@ -1514,24 +1514,23 @@ export function mapCodexPatchNotificationToToolCall(params: {
}
function mapCodexTerminalInteractionToToolCall(params: {
callId: string;
processId?: string | null;
fallbackCallId?: string | null;
command?: string | null;
stdin?: string | null;
}): ToolCallTimelineItem {
const processId = nonEmptyString(params.processId ?? undefined);
const callId = processId
? `terminal-session-${processId}`
: (nonEmptyString(params.fallbackCallId ?? undefined) ?? "terminal-interaction");
const label = nonEmptyString(params.command ?? undefined);
return {
type: "tool_call",
callId,
callId: params.callId,
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
...(label ? { label } : {}),
...(params.stdin !== null && params.stdin !== undefined ? { text: params.stdin } : {}),
icon: "square_terminal",
},
...(processId ? { metadata: { processId } } : {}),
@@ -2111,8 +2110,8 @@ const CodexEventExecCommandEndNotificationSchema = z
cwd: z.string().optional(),
stdout: z.string().optional(),
stderr: z.string().optional(),
aggregated_output: z.string().optional(),
aggregatedOutput: z.string().optional(),
aggregated_output: z.string().nullable().optional(),
aggregatedOutput: z.string().nullable().optional(),
formatted_output: z.string().optional(),
exit_code: 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 pendingAssistantMessageBoundary = false;
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 emittedExecCommandStartedCallIds = new Set<string>();
private emittedExecCommandCompletedCallIds = new Set<string>();
@@ -5447,13 +5450,18 @@ export class CodexAppServerAgentSession implements AgentSession {
const command =
(parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ??
null;
const callId = this.createTerminalInteractionCallId(parsed.processId, parsed.callId);
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({
callId,
processId: parsed.processId,
fallbackCallId: parsed.callId,
command,
stdin: parsed.stdin,
});
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
@@ -5862,15 +5870,31 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.pendingUnlabeledTerminalInteractions.has(processId)) {
return;
}
const pendingInteractions = this.pendingUnlabeledTerminalInteractions.get(processId) ?? [];
this.pendingUnlabeledTerminalInteractions.delete(processId);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexTerminalInteractionToToolCall({
processId,
command: displayCommand,
}),
});
for (const pendingInteraction of pendingInteractions) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexTerminalInteractionToToolCall({
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 {

View File

@@ -19,6 +19,19 @@ interface FakeLegacyCommand {
command: 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 {
threadId: string;
callId: string;
@@ -51,6 +64,9 @@ export interface FakeCodexAppServer {
runsLegacyCommand(params: FakeLegacyCommand): void;
appliesLegacyPatch(params: FakeLegacyPatch): 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;
requestCommandApproval(params: {
itemId: string;
@@ -366,6 +382,37 @@ export function createFakeCodexAppServer(
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) {
if (params.itemId) {
for (const chunk of params.chunks ?? [params.text]) {
@@ -458,21 +505,53 @@ function toJsonObject(value: unknown): JsonObject {
return {};
}
export function waitForNextPermission(
type StreamEventType = AgentStreamEvent["type"];
type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent, { type: TType }>;
function waitForNextEvent<TType extends StreamEventType>(
session: AgentSession,
): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
type: TType,
accepts?: (event: StreamEventOfType<TType>) => boolean,
): Promise<StreamEventOfType<TType>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for permission_requested"));
reject(new Error(`Timed out waiting for ${type}`));
}, 1000);
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;
}
clearTimeout(timeout);
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,
);
}

View File

@@ -141,7 +141,7 @@ const CodexCommandExecutionItemSchema = z
error: z.unknown().optional(),
command: CodexCommandValueSchema.optional(),
cwd: z.string().optional(),
aggregatedOutput: z.string().optional(),
aggregatedOutput: z.string().nullable().optional(),
exitCode: z.number().nullable().optional(),
})
.passthrough();
@@ -694,7 +694,7 @@ function mapCommandExecutionItem(
item: z.infer<typeof CodexCommandExecutionItemSchema>,
): CodexNormalizedToolCallEnvelope {
const command = normalizeCommandExecutionCommand(item.command);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput);
const parsedOutput = extractCodexShellOutput(item.aggregatedOutput ?? undefined);
const input = toNullableObject({
...(command !== undefined ? { command } : {}),
...(item.cwd !== undefined ? { cwd: item.cwd } : {}),