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 () => {
|
||||
|
||||
@@ -198,13 +198,20 @@ export async function selectGithubOption(
|
||||
export interface MockAgentSetup {
|
||||
client: SeedDaemonClient;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
|
||||
export async function startRunningMockAgent(
|
||||
page: Page,
|
||||
opts: { prefix: string; model: string; prompt: string },
|
||||
opts: {
|
||||
prefix: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<MockAgentSetup> {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -222,6 +229,7 @@ export async function startRunningMockAgent(
|
||||
cwd: repo.path,
|
||||
workspaceId: workspace.id,
|
||||
model: opts.model,
|
||||
featureValues: opts.featureValues,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
@@ -233,6 +241,8 @@ export async function startRunningMockAgent(
|
||||
return {
|
||||
client,
|
||||
repo,
|
||||
workspaceId: workspace.id,
|
||||
agentId: agent.id,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
@@ -20,6 +20,9 @@ interface ClientRequest {
|
||||
payload?: unknown;
|
||||
mode?: unknown;
|
||||
path?: unknown;
|
||||
agentId?: unknown;
|
||||
text?: unknown;
|
||||
activeTurnBehavior?: unknown;
|
||||
}
|
||||
|
||||
interface ServerMessage {
|
||||
@@ -48,6 +51,11 @@ interface HeldServerMessage {
|
||||
blockedAgentId?: string;
|
||||
}
|
||||
|
||||
interface PendingServerMessageHold {
|
||||
matches: (message: ClientRequest | null) => boolean;
|
||||
blockAgentStreamFollowers?: boolean;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: string | Buffer): ClientRequest | null {
|
||||
try {
|
||||
const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as {
|
||||
@@ -125,6 +133,25 @@ function forceTimelineReset(message: string | Buffer, enabled: boolean): string
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function rewriteShellToolCommand(
|
||||
message: string | Buffer,
|
||||
command: string | null,
|
||||
): string | Buffer {
|
||||
if (!command || typeof message !== "string") return message;
|
||||
const envelope = JSON.parse(message) as {
|
||||
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
|
||||
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
|
||||
};
|
||||
const event = (envelope.message?.payload ?? envelope.payload)?.event;
|
||||
if (event?.type !== "timeline" || event.item?.type !== "tool_call") return message;
|
||||
const detail = event.item.detail;
|
||||
if (!detail || typeof detail !== "object" || (detail as { type?: unknown }).type !== "shell") {
|
||||
return message;
|
||||
}
|
||||
(detail as { command?: unknown }).command = command;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function readAgentStreamEventType(message: ClientRequest | null): string | null {
|
||||
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
|
||||
return null;
|
||||
@@ -152,6 +179,36 @@ function readAgentStreamItemType(message: ClientRequest | null): string | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function matchesToolCall(
|
||||
message: ClientRequest | null,
|
||||
input: { status: string; command?: string },
|
||||
): boolean {
|
||||
if (readAgentStreamItemType(message) !== "tool_call") return false;
|
||||
const event = (
|
||||
message?.payload as
|
||||
| {
|
||||
event?: { item?: { status?: unknown; detail?: { command?: unknown } } };
|
||||
}
|
||||
| undefined
|
||||
)?.event;
|
||||
return (
|
||||
event?.item?.status === input.status &&
|
||||
(input.command === undefined || event.item.detail?.command === input.command)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesShellToolCall(message: ClientRequest | null, status: string): boolean {
|
||||
if (readAgentStreamItemType(message) !== "tool_call") return false;
|
||||
const event = (
|
||||
message?.payload as
|
||||
| {
|
||||
event?: { item?: { status?: unknown; detail?: { type?: unknown } } };
|
||||
}
|
||||
| undefined
|
||||
)?.event;
|
||||
return event?.item?.status === status && event.item.detail?.type === "shell";
|
||||
}
|
||||
|
||||
function shouldSuppressServerMessage(input: {
|
||||
message: ClientRequest | null;
|
||||
messageTypes: ReadonlySet<string>;
|
||||
@@ -226,10 +283,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
let forceTimelineEpochReset = false;
|
||||
let stripAssistantMessageIds = false;
|
||||
let stripCanonicalSubmittedPromptsFeature = false;
|
||||
let shellToolCommandOverride: string | null = null;
|
||||
let heldClientRequestType: string | null = null;
|
||||
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
|
||||
let resolveHeldClientRequest: (() => void) | null = null;
|
||||
const pendingServerMessageHolds = new Map<string, (message: ClientRequest | null) => boolean>();
|
||||
const pendingServerMessageHolds = new Map<string, PendingServerMessageHold>();
|
||||
const heldServerMessages: HeldServerMessage[] = [];
|
||||
const heldServerMessageWaiters = new Set<() => void>();
|
||||
const suppressedServerMessageTypes = new Set<string>();
|
||||
@@ -243,6 +301,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
total: { agents: 0, workspaces: 0 },
|
||||
};
|
||||
const clientRequestCounts = new Map<string, number>();
|
||||
const clientRequests = new Map<string, ClientRequest[]>();
|
||||
const timelineRequestCounts = new Map<string, number>();
|
||||
const serverMessageCounts = new Map<string, number>();
|
||||
const agentStreamEventCounts = new Map<string, number>();
|
||||
@@ -322,11 +381,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
if (!suppressed) blocked.agentStreamFollowers.push(input.message);
|
||||
return true;
|
||||
}
|
||||
const matchedHold = Array.from(pendingServerMessageHolds).find(([, matches]) =>
|
||||
matches(input.parsed),
|
||||
const matchedHold = Array.from(pendingServerMessageHolds).find(([, hold]) =>
|
||||
hold.matches(input.parsed),
|
||||
);
|
||||
if (!matchedHold) return suppressed;
|
||||
const [key] = matchedHold;
|
||||
const [key, hold] = matchedHold;
|
||||
pendingServerMessageHolds.delete(key);
|
||||
heldServerMessages.push({
|
||||
browser: input.browser,
|
||||
@@ -334,7 +393,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
key,
|
||||
agentStreamFollowers: [],
|
||||
blockedAgentId:
|
||||
readAgentStreamEventType(input.parsed) === "turn_started"
|
||||
hold.blockAgentStreamFollowers || readAgentStreamEventType(input.parsed) === "turn_started"
|
||||
? (agentId ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
@@ -385,6 +444,11 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
}
|
||||
const request = readClientRequest(message);
|
||||
recordClientRequest(request, clientRequestCounts, timelineRequestCounts, directoryStarts);
|
||||
if (typeof request?.type === "string") {
|
||||
const requests = clientRequests.get(request.type) ?? [];
|
||||
requests.push(request);
|
||||
clientRequests.set(request.type, requests);
|
||||
}
|
||||
if (request?.type === heldClientRequestType) {
|
||||
heldClientRequest = { server, message };
|
||||
resolveHeldClientRequest?.();
|
||||
@@ -417,6 +481,7 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
stripAssistantMessageIds,
|
||||
serverMessage?.type,
|
||||
);
|
||||
outboundMessage = rewriteShellToolCommand(outboundMessage, shellToolCommandOverride);
|
||||
outboundMessage = stripCanonicalSubmittedPrompts(
|
||||
outboundMessage,
|
||||
stripCanonicalSubmittedPromptsFeature,
|
||||
@@ -516,25 +581,36 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
heldClientRequestType = null;
|
||||
},
|
||||
holdNextServerMessage(type: string): void {
|
||||
pendingServerMessageHolds.set(serverMessageKey(type), (message) => message?.type === type);
|
||||
pendingServerMessageHolds.set(serverMessageKey(type), {
|
||||
matches: (message) => message?.type === type,
|
||||
});
|
||||
},
|
||||
holdNextAgentUpdate(agentId: string, status: string): void {
|
||||
const heldAgentUpdate = { agentId, status };
|
||||
pendingServerMessageHolds.set(agentUpdateKey(agentId, status), (message) =>
|
||||
matchesAgentUpdate(message, heldAgentUpdate),
|
||||
);
|
||||
pendingServerMessageHolds.set(agentUpdateKey(agentId, status), {
|
||||
matches: (message) => matchesAgentUpdate(message, heldAgentUpdate),
|
||||
});
|
||||
},
|
||||
holdNextAgentStreamEvent(type: string): void {
|
||||
pendingServerMessageHolds.set(
|
||||
agentStreamEventKey(type),
|
||||
(message) => readAgentStreamEventType(message) === type,
|
||||
);
|
||||
pendingServerMessageHolds.set(agentStreamEventKey(type), {
|
||||
matches: (message) => readAgentStreamEventType(message) === type,
|
||||
});
|
||||
},
|
||||
holdNextAgentStreamItem(type: string): void {
|
||||
pendingServerMessageHolds.set(
|
||||
agentStreamItemKey(type),
|
||||
(message) => readAgentStreamItemType(message) === type,
|
||||
);
|
||||
pendingServerMessageHolds.set(agentStreamItemKey(type), {
|
||||
matches: (message) => readAgentStreamItemType(message) === type,
|
||||
});
|
||||
},
|
||||
holdNextToolCall(input: { status: string; command?: string }): void {
|
||||
pendingServerMessageHolds.set(`tool-call:${input.status}:${input.command ?? ""}`, {
|
||||
matches: (message) => matchesToolCall(message, input),
|
||||
});
|
||||
},
|
||||
holdNextShellToolCall(status: string): void {
|
||||
pendingServerMessageHolds.set(`shell-tool-call:${status}`, {
|
||||
matches: (message) => matchesShellToolCall(message, status),
|
||||
blockAgentStreamFollowers: true,
|
||||
});
|
||||
},
|
||||
async waitForHeldServerMessage(type?: string): Promise<void> {
|
||||
const key = type ? serverMessageKey(type) : null;
|
||||
@@ -686,6 +762,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
setCanonicalSubmittedPromptsStripped(stripped: boolean): void {
|
||||
stripCanonicalSubmittedPromptsFeature = stripped;
|
||||
},
|
||||
setShellToolCommandOverride(command: string | null): void {
|
||||
shellToolCommandOverride = command;
|
||||
},
|
||||
setAgentStreamSuppressed(suppressed: boolean): void {
|
||||
suppressAgentStream = suppressed;
|
||||
},
|
||||
@@ -702,6 +781,9 @@ export async function installDaemonWebSocketGate(page: Page) {
|
||||
getClientRequestCount(type: string): number {
|
||||
return clientRequestCounts.get(type) ?? 0;
|
||||
},
|
||||
getClientRequests(type: string): ReadonlyArray<ClientRequest> {
|
||||
return [...(clientRequests.get(type) ?? [])];
|
||||
},
|
||||
getTimelineRequestCount(direction: "tail" | "before" | "after"): number {
|
||||
return timelineRequestCounts.get(direction) ?? 0;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user