mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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 {
|
||||
item = limitFailedShellError(item);
|
||||
item = limitPlainText(item);
|
||||
if (
|
||||
item.type !== "tool_call" ||
|
||||
item.detail.type !== "shell" ||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user