mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
29
packages/app/e2e/out-of-band-command.codex.real.spec.ts
Normal file
29
packages/app/e2e/out-of-band-command.codex.real.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user