Show the command on Codex terminal rows (#5774)

* fix(codex): label terminal writes with the command Codex started

Codex app-server reports the PTY process id on the commandExecution item,
and no longer sends the legacy exec_command_end event that carried it as
text. Record the process-to-command label from the item so terminal
writes show which command they went to.

* ci: rerun after website e2e server start timeout

* fix(codex): unwrap the shell from terminal labels like shell rows

Terminal labels now use the same command normalization as the shell row,
so a Linux wrapper such as /usr/bin/zsh -lc 'python3 -i' labels the
terminal as python3 -i.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-30 20:50:24 +02:00
committed by GitHub
parent 9d9d9af993
commit 7a64eef491
4 changed files with 83 additions and 13 deletions
@@ -1595,6 +1595,51 @@ describe("Codex app-server provider", () => {
}
});
test.each(["python3 -i", "/usr/bin/zsh -lc 'python3 -i'"])(
"labels a terminal write started as %s with the command that started the terminal",
async (command) => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
appServer.startsTerminalCommand({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "73",
command,
});
const terminalWrite = waitForTimelineToolCall(session, "terminal-session-73-1");
appServer.typesIntoTerminal({
threadId: "thread-1",
turnId: "turn-1",
itemId: "interactive-shell",
processId: "73",
text: "print(1)\n",
});
await expect(terminalWrite).resolves.toMatchObject({
item: {
callId: "terminal-session-73-1",
name: "terminal",
detail: { type: "plain_text", label: "python3 -i", text: "print(1)\n" },
metadata: { processId: "73" },
},
});
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(
@@ -57,6 +57,7 @@ import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async
import {
mapCodexToolCallEnvelope,
mapCodexToolCallFromThreadItem,
normalizeCommandExecutionCommand,
splitCodexMcpToolResultImages,
} from "./codex/tool-call-mapper.js";
import {
@@ -6731,6 +6732,7 @@ export class CodexAppServerAgentSession implements AgentSession {
);
const itemId = parsed.item.id;
if (normalizedItemType === "commandExecution") {
this.rememberTerminalProcessForItem(parsed.item);
const callId = timelineItem.callId || itemId;
if (callId && this.emittedExecCommandStartedCallIds.has(callId)) {
return;
@@ -6850,19 +6852,21 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private rememberTerminalProcessForCommand(command: unknown, output: string | null): void {
const normalizedCommand = normalizeCodexCommandValue(command);
if (!normalizedCommand) {
return;
}
const displayCommand =
typeof normalizedCommand === "string"
? normalizedCommand
: normalizedCommand.join(" ").trim();
if (!displayCommand) {
return;
}
const processId = extractCodexTerminalSessionId(output ?? undefined);
if (!processId) {
if (processId) {
this.rememberTerminalProcess(processId, command);
}
}
private rememberTerminalProcessForItem(item: { [key: string]: unknown }): void {
if (typeof item.processId === "string" && item.processId) {
this.rememberTerminalProcess(item.processId, item.command);
}
}
private rememberTerminalProcess(processId: string, command: unknown): void {
const displayCommand = normalizeCommandExecutionCommand(command);
if (!displayCommand) {
return;
}
this.terminalCommandByProcessId.set(processId, displayCommand);
@@ -25,6 +25,13 @@ interface FakeSilentCommand {
command: string;
cwd: string;
}
interface FakeTerminalCommand {
threadId: string;
turnId: string;
itemId: string;
processId: string;
command: string;
}
interface FakeTerminalInput {
threadId: string;
turnId: string;
@@ -81,6 +88,7 @@ export interface FakeCodexAppServer {
completesCommand(params: FakeLegacyCommand): void;
completesSilentCommand(params: FakeSilentCommand): void;
completesSilentLegacyCommand(params: FakeSilentCommand): void;
startsTerminalCommand(params: FakeTerminalCommand): void;
typesIntoTerminal(params: FakeTerminalInput): void;
says(params: { threadId: string; itemId?: string; text: string; chunks?: string[] }): void;
requestCommandApproval(params: {
@@ -487,6 +495,19 @@ export function createFakeCodexAppServer(
success: true,
});
},
startsTerminalCommand(params) {
writeNotification("item/started", {
threadId: params.threadId,
turnId: params.turnId,
item: {
type: "commandExecution",
id: params.itemId,
status: "inProgress",
command: params.command,
processId: params.processId,
},
});
},
typesIntoTerminal(params) {
writeNotification("item/commandExecution/terminalInteraction", {
threadId: params.threadId,
@@ -245,7 +245,7 @@ function isWindowsShellCommand(command: string): boolean {
return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized);
}
function normalizeCommandExecutionCommand(value: unknown): string | undefined {
export function normalizeCommandExecutionCommand(value: unknown): string | undefined {
if (typeof value === "string") {
const normalized = unwrapShellCommand(value);
return normalized.length > 0 ? normalized : undefined;