diff --git a/packages/server/src/server/agent/agent-timeline-content.test.ts b/packages/server/src/server/agent/agent-timeline-content.test.ts new file mode 100644 index 000000000..f3b94e248 --- /dev/null +++ b/packages/server/src/server/agent/agent-timeline-content.test.ts @@ -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", + }, + }); + }); +}); diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index 5bdc17e0a..ab436152e 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -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" || diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 5522feaa8..8f77da542 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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( diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index aa23cf47f..dc77fdb7e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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(); private pendingAssistantMessageBoundary = false; private terminalCommandByProcessId = new Map(); - private pendingUnlabeledTerminalInteractions = new Set(); + private pendingUnlabeledTerminalInteractions = new Map< + string, + Array<{ callId: string; stdin: string | null }> + >(); + private nextTerminalInteractionOrdinal = 0; private emittedTerminalInteractionKeys = new Set(); private emittedExecCommandStartedCallIds = new Set(); private emittedExecCommandCompletedCallIds = new Set(); @@ -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 { diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 8302e6d93..9ef0c354e 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -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 = Extract; + +function waitForNextEvent( session: AgentSession, -): Promise> { + type: TType, + accepts?: (event: StreamEventOfType) => boolean, +): Promise> { 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; + if (accepts && !accepts(typedEvent)) { return; } clearTimeout(timeout); unsubscribe(); - resolve(event); + resolve(typedEvent); }); }); } + +type TimelineEvent = StreamEventOfType<"timeline">; + +export function waitForNextPermission( + session: AgentSession, +): Promise> { + return waitForNextEvent(session, "permission_requested"); +} + +export function waitForNextTimelineItem(session: AgentSession): Promise { + return waitForNextEvent(session, "timeline"); +} + +export function waitForTimelineToolCall( + session: AgentSession, + callId: string, +): Promise { + return waitForNextEvent( + session, + "timeline", + (event) => event.item.type === "tool_call" && event.item.callId === callId, + ); +} diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 5f488cc4a..967b6b43a 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -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, ): 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 } : {}),