fix(agent): settle out-of-band message submissions

Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer.
This commit is contained in:
Mohamed Boudra
2026-07-27 18:54:15 +02:00
parent 1464818fc1
commit eec9fb3992
3 changed files with 47 additions and 2 deletions

View File

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

View File

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

View File

@@ -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));