Add active-turn steering (#3394)
* feat(agents): add active-turn steering Let users choose whether an immediate prompt interrupts or joins the active turn. The daemon owns exact-turn admission, provider delivery, safe fallback, and canonical timeline identity while queueing remains client-side. * fix(agents): stabilize steering history and replay QA * fix(agents): preserve steering timeline order * fix(agents): reconcile resumed provider history * fix(agents): serialize steering with cancellation * test(agents): prove Claude multi-tool steering * test(agents): force explicit history hydration * fix(agents): flush output before steer admission
This commit is contained in:
@@ -30,13 +30,14 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES } from "@/screens/workspace/workspace-deck-retention";
|
||||
import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { selectModel } from "../support/helpers/app";
|
||||
import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app";
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
import {
|
||||
expectResumeOverflowFallsBackToOneTail,
|
||||
rememberTimelineRequestCounts,
|
||||
} from "../support/helpers/timeline-resume";
|
||||
import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui";
|
||||
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
|
||||
import {
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
@@ -264,14 +265,20 @@ async function submitMessageThatWillBeRejected(page: Page, prompt: string): Prom
|
||||
|
||||
async function expectRejectedSubmissionRestored(
|
||||
page: Page,
|
||||
input: { prompt: string; errorMessage: string },
|
||||
input: { prompt: string; errorMessage: string; preservesActiveTurn?: boolean },
|
||||
): Promise<void> {
|
||||
await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("alert").filter({ hasText: input.errorMessage })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerDraft(page, input.prompt);
|
||||
await expectComposerEditable(page);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
|
||||
if (input.preservesActiveTurn) {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
return;
|
||||
}
|
||||
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -284,6 +291,60 @@ async function retryRestoredSubmission(page: Page, prompt: string): Promise<void
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function configureSteerInSettings(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await selectSteerInSettings(page);
|
||||
}
|
||||
|
||||
async function selectSteerInSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Steer", exact: true }).click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||
return raw ? (JSON.parse(raw) as { sendBehavior?: unknown }).sendBehavior : null;
|
||||
})
|
||||
.toBe("steer");
|
||||
}
|
||||
|
||||
async function replaySteeredSleepTurnInBrowser(
|
||||
page: Page,
|
||||
testInfo: { workerIndex: number },
|
||||
shape: "claude" | "codex",
|
||||
): Promise<void> {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
gate.holdNextShellToolCall("completed");
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await selectSteerInSettings(page);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `steer-replay-${shape}-${testInfo.workerIndex}-`,
|
||||
model: "ten-second-stream",
|
||||
prompt: `Replay a ${shape}-shaped foreground shell tool call while the user steers this turn.`,
|
||||
});
|
||||
try {
|
||||
await expect(page.getByTestId("tool-call-badge").last()).toBeVisible({ timeout: 30_000 });
|
||||
await expectComposerVisible(page);
|
||||
await submitMessage(page, "hello");
|
||||
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(0);
|
||||
await expectInFlightForkAvailable(page);
|
||||
|
||||
await gate.waitForHeldServerMessage();
|
||||
gate.releaseHeldServerMessage();
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "Fork chat" }).last()).toBeVisible();
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function queueMessage(page: Page, prompt: string): Promise<void> {
|
||||
await fillComposerDraft(page, prompt);
|
||||
await sendDraftToQueue(page);
|
||||
@@ -1030,6 +1091,83 @@ test.describe("Agent message submission", () => {
|
||||
await retryRestoredSubmission(page, prompt);
|
||||
});
|
||||
|
||||
test("restores an ambiguous Steer failure without retrying or interrupting", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const prompt = "Restore this ambiguous Steer submission.";
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `steer-ambiguous-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep this turn active while a Steer request fails.",
|
||||
featureValues: { mockSteerAmbiguousFailures: 1 },
|
||||
});
|
||||
try {
|
||||
await configureSteerInSettings(page);
|
||||
await page.goBack();
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
const sendsBefore = gate.getClientRequestCount("send_agent_message_request");
|
||||
const cancelsBefore = gate.getClientRequestCount("cancel_agent_request");
|
||||
|
||||
await submitMessageThatWillBeRejected(page, prompt);
|
||||
await expectRejectedSubmissionRestored(page, {
|
||||
prompt,
|
||||
errorMessage: "Requested mock steer transport failure",
|
||||
preservesActiveTurn: true,
|
||||
});
|
||||
|
||||
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
|
||||
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
|
||||
expect(gate.getClientRequests("send_agent_message_request").at(-1)).toMatchObject({
|
||||
text: prompt,
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an optimistic Steer prompt inside the active turn before acknowledgement", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `steer-optimistic-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep this turn active while the user steers it.",
|
||||
});
|
||||
try {
|
||||
await configureSteerInSettings(page);
|
||||
await page.goBack();
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
gate.holdNextServerMessage("send_agent_message_response");
|
||||
await submitMessage(page, "hello");
|
||||
await gate.waitForHeldServerMessage("send_agent_message_response");
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText(/^Worked for/)).toHaveCount(0);
|
||||
gate.releaseHeldServerMessage("send_agent_message_response");
|
||||
await expect(page.getByText("hello", { exact: true })).toHaveCount(1);
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
|
||||
});
|
||||
|
||||
test("replays Codex-shaped steering inside one active turn", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await replaySteeredSleepTurnInBrowser(page, testInfo, "codex");
|
||||
});
|
||||
|
||||
test("restores overlapping queued sends when their connection fails", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
|
||||
|
||||
async function expectSendBehavior(
|
||||
page: import("@playwright/test").Page,
|
||||
expected: "interrupt" | "queue",
|
||||
expected: "interrupt" | "queue" | "steer",
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
Reference in New Issue
Block a user