diff --git a/packages/app/e2e/out-of-band-command.codex.real.spec.ts b/packages/app/e2e/out-of-band-command.codex.real.spec.ts new file mode 100644 index 000000000..cbec6733c --- /dev/null +++ b/packages/app/e2e/out-of-band-command.codex.real.spec.ts @@ -0,0 +1,29 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "./fixtures"; +import { submitMessage } from "./helpers/composer"; +import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow"; + +test.describe("Codex out-of-band commands", () => { + test.setTimeout(300_000); + + test("settles the submitted row when a goal command completes without a turn", async ({ + page, + }) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" }); + await submitMessage(page, "/goal clear"); + const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" }); + + await expect(command).toBeVisible(); + await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index dc87d3196..7b1c7d478 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1952,12 +1952,19 @@ export class AgentManager { * emitted by the handler flow through dispatchStream so they persist and * broadcast like normal timeline events. */ - tryRunOutOfBand(agentId: string, prompt: AgentPromptInput): boolean { + tryRunOutOfBand( + agentId: string, + prompt: AgentPromptInput, + runOptions?: AgentRunOptions, + ): boolean { const agent = this.requireSessionAgent(agentId); const handler = agent.session.tryHandleOutOfBand?.(prompt); if (!handler) { return false; } + if (typeof prompt !== "string") { + throw new Error("Out-of-band commands require a text prompt"); + } const dispatch = (event: AgentStreamEvent): void => { // Persist timeline items so they show up in fetchAgentTimeline; broadcast // for live subscribers. Other event types are broadcast only. @@ -1973,6 +1980,15 @@ export class AgentManager { } this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() }); }; + dispatch({ + type: "timeline", + provider: agent.provider, + item: { + type: "user_message", + text: prompt, + ...(runOptions?.clientMessageId ? { clientMessageId: runOptions.clientMessageId } : {}), + }, + }); void (async () => { try { await handler.run({ emit: dispatch }); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 22bd8cdc2..53d367416 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -41,7 +41,7 @@ export async function startAgentRun( // Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an // in-flight turn — replaceAgentRun would interrupt the running turn. The // intercept lives at this layer so it covers every prompt entrypoint. - if (agentManager.tryRunOutOfBand(agentId, prompt)) { + if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { return { outOfBand: true }; } const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId));