feat(app): default to steering when sending into a running agent
Steer is the better default for Enter while an agent is running, but flipping DEFAULT_CLIENT_SETTINGS alone would only reach new installs. Client defaults are materialized into storage — the first load writes the whole default blob and every save re-stamps it — so every existing install already has "interrupt" on disk, and a stored "interrupt" cannot be told apart from one the user actually chose. A one-time migration flips them all; picking "interrupt" afterwards sticks. The applied-migration marker sits in its own storage key rather than in the settings blob: StoredAppSettingsSchema is strict and readValidatedJson clears the value when parsing fails, so an unknown field there would wipe every setting for anyone who downgrades. migrateAppSettings owns both of its writes so the settings blob always lands before the marker. A failed marker write leaves the migration to re-run harmlessly; a failed settings write must leave the marker unwritten, or the migration is lost and the user keeps "interrupt" for good.
This commit is contained in:
@@ -302,13 +302,29 @@ async function configureSteerInSettings(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function selectSteerInSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Steer", exact: true }).click();
|
||||
await selectSendBehaviorInSettings(page, "Steer", "steer");
|
||||
}
|
||||
|
||||
/** Steer is the default, so the interrupt path only gets exercised by opting back into it. */
|
||||
async function configureInterruptInSettings(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await selectSendBehaviorInSettings(page, "Interrupt", "interrupt");
|
||||
}
|
||||
|
||||
async function selectSendBehaviorInSettings(
|
||||
page: Page,
|
||||
buttonName: string,
|
||||
stored: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: buttonName, 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");
|
||||
.toBe(stored);
|
||||
}
|
||||
|
||||
async function replaySteeredSleepTurnInBrowser(
|
||||
@@ -932,9 +948,9 @@ test.describe("Agent message submission", () => {
|
||||
|
||||
gate.holdNextClientRequest("send_agent_message_request");
|
||||
await fillComposerDraft(page, "Replace the running turn without duplicating its action.");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Send and interrupt", exact: true }),
|
||||
).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "Send and steer", exact: true })).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Interrupt agent", exact: true })).toHaveCount(
|
||||
0,
|
||||
@@ -1161,6 +1177,38 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("sends interrupt behavior on the wire when the user opts out of steering", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await gateNextAgentMessage(page);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: `interrupt-submission-${testInfo.workerIndex}-`,
|
||||
model: "one-minute-stream",
|
||||
prompt: "Keep this turn active until the user interrupts it.",
|
||||
});
|
||||
try {
|
||||
await configureInterruptInSettings(page);
|
||||
await page.goBack();
|
||||
await expectComposerVisible(page);
|
||||
await expectAgentReadyToInterrupt(page);
|
||||
|
||||
const prompt = "Interrupt the running turn.";
|
||||
await fillComposerDraft(page, prompt);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Send and interrupt", exact: true }),
|
||||
).toHaveCount(1);
|
||||
await composerLocator(page).press("Enter");
|
||||
|
||||
const request = await gate.waitForRequest();
|
||||
expect(request.activeTurnBehavior).toBe("interrupt");
|
||||
gate.accept();
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays Claude-shaped steering inside one active turn", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await replaySteeredSleepTurnInBrowser(page, testInfo, "claude");
|
||||
|
||||
@@ -7,6 +7,8 @@ interface SendAgentMessageRequest {
|
||||
type: "send_agent_message_request";
|
||||
requestId: string;
|
||||
agentId: string;
|
||||
/** Absent when the client sends into an idle agent. */
|
||||
activeTurnBehavior?: string;
|
||||
}
|
||||
|
||||
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
|
||||
@@ -28,6 +30,8 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n
|
||||
type: "send_agent_message_request",
|
||||
requestId: request.requestId,
|
||||
agentId: request.agentId,
|
||||
activeTurnBehavior:
|
||||
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user