fix: preserve resumed agents after accepted turn cancellation (#3791)

This commit is contained in:
Carlos Florêncio
2026-09-18 10:33:26 +01:00
committed by GitHub
parent b1b095f0c0
commit 2f15db8a14
21 changed files with 2069 additions and 32 deletions
@@ -6,8 +6,12 @@ import { waitForSessionState } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
import {
cleanupDelayedResumeFixture,
countResumeBootMessages,
createFailOnResumeProfile,
readSessionMessageIdsContaining,
readSessionRuntimeIdentity,
seedDelayedResumeFixture,
waitForNewSessionMessage,
waitForSessionReady,
} from "../../helpers/session-resume-prompt-queue";
import {
@@ -101,6 +105,116 @@ test.describe("mobile: delayed resume cancellation", () => {
await cleanupDelayedResumeFixture(apiClient, fixture);
}
});
test("pausing an accepted lazy resume preserves the runtime for later turns", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(150_000);
await apiClient.saveUserSettings({ prevent_auto_start_agent_on_open: true });
try {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile accepted lazy resume pause recovery",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("mobile accepted lazy resume task has no session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 30_000,
});
await session.waitForChatIdle({ timeout: 30_000 });
await backend.restart();
await testPage.reload();
await session.waitForLoad();
await expect(testPage.getByTestId("composer-agent-start-hint")).toBeVisible({
timeout: 60_000,
});
const resumeBootsBeforeMessage = await countResumeBootMessages(apiClient, task.session_id);
// Provider output proves that the resumed prompt crossed acceptance
// before the touch cancellation is sent.
await session.sendMessageViaButton("/slow 8s");
await expect(session.chat.getByText("Running slow response", { exact: false })).toBeVisible({
timeout: 30_000,
});
const initialRuntimeIdentity = await readSessionRuntimeIdentity(
apiClient,
task.id,
task.session_id,
);
await session.cancelAgentButton().tap();
await waitForSessionState(apiClient, {
taskId: task.id,
sessionId: task.session_id,
expectedState: "WAITING_FOR_INPUT",
message: "Waiting for mobile accepted lazy resume cancellation",
timeout: 30_000,
});
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
expect(await readSessionRuntimeIdentity(apiClient, task.id, task.session_id)).toEqual(
initialRuntimeIdentity,
);
const resumeBootsAfterFirstPause = await countResumeBootMessages(apiClient, task.session_id);
expect(resumeBootsAfterFirstPause).toBe(resumeBootsBeforeMessage + 1);
const slowResponseMessageIdsBeforeSecond = await readSessionMessageIdsContaining(
apiClient,
task.session_id,
"Running slow response",
);
expect(slowResponseMessageIdsBeforeSecond.size).toBeGreaterThan(0);
await session.sendMessageViaButton("/slow 8s");
await waitForNewSessionMessage(
apiClient,
task.session_id,
slowResponseMessageIdsBeforeSecond,
"Running slow response",
);
await session.cancelAgentButton().tap();
await waitForSessionState(apiClient, {
taskId: task.id,
sessionId: task.session_id,
expectedState: "WAITING_FOR_INPUT",
message: "Waiting for the later mobile accepted pause",
timeout: 30_000,
});
expect(await readSessionRuntimeIdentity(apiClient, task.id, task.session_id)).toEqual(
initialRuntimeIdentity,
);
expect(await countResumeBootMessages(apiClient, task.session_id)).toBe(
resumeBootsAfterFirstPause,
);
await session.sendMessageViaButton("/e2e:simple-message");
await session.expectChatResponseVisible("simple mock response", 1, { timeout: 30_000 });
expect(await readSessionRuntimeIdentity(apiClient, task.id, task.session_id)).toEqual(
initialRuntimeIdentity,
);
expect(await countResumeBootMessages(apiClient, task.session_id)).toBe(
resumeBootsAfterFirstPause,
);
await assertNoDocumentHorizontalOverflow(testPage, "mobile accepted lazy resume pause");
} finally {
await apiClient.saveUserSettings({ prevent_auto_start_agent_on_open: false });
}
});
});
test.describe("mobile: failed resume recovery", () => {
@@ -6,8 +6,12 @@ import { waitForSessionState } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
import {
cleanupDelayedResumeFixture,
countResumeBootMessages,
createFailOnResumeProfile,
readSessionMessageIdsContaining,
readSessionRuntimeIdentity,
seedDelayedResumeFixture,
waitForNewSessionMessage,
waitForSessionReady,
waitForQueuedCount,
} from "../../helpers/session-resume-prompt-queue";
@@ -153,6 +157,116 @@ test.describe("Session recovery", () => {
}
});
test("pausing an accepted lazy resume preserves the runtime for later turns", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(150_000);
await apiClient.saveUserSettings({ prevent_auto_start_agent_on_open: true });
try {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Accepted lazy resume pause recovery",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("accepted lazy resume task has no session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 30_000,
});
await session.waitForChatIdle({ timeout: 30_000 });
await backend.restart();
await testPage.reload();
await session.waitForLoad();
await expect(testPage.getByTestId("composer-agent-start-hint")).toBeVisible({
timeout: 60_000,
});
const resumeBootsBeforeMessage = await countResumeBootMessages(apiClient, task.session_id);
// The slow response is the acceptance witness: provider output can only
// arrive after the resumed prompt crossed the dispatch callback.
await session.sendMessage("/slow 8s");
await expect(session.chat.getByText("Running slow response", { exact: false })).toBeVisible({
timeout: 30_000,
});
const initialRuntimeIdentity = await readSessionRuntimeIdentity(
apiClient,
task.id,
task.session_id,
);
await session.cancelAgentButton().click();
await waitForSessionState(apiClient, {
taskId: task.id,
sessionId: task.session_id,
expectedState: "WAITING_FOR_INPUT",
message: "Waiting for accepted lazy resume cancellation",
timeout: 30_000,
});
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
expect(await readSessionRuntimeIdentity(apiClient, task.id, task.session_id)).toEqual(
initialRuntimeIdentity,
);
const resumeBootsAfterFirstPause = await countResumeBootMessages(apiClient, task.session_id);
expect(resumeBootsAfterFirstPause).toBe(resumeBootsBeforeMessage + 1);
// A second accepted pause must use the same process and conversation.
const slowResponseMessageIdsBeforeSecond = await readSessionMessageIdsContaining(
apiClient,
task.session_id,
"Running slow response",
);
expect(slowResponseMessageIdsBeforeSecond.size).toBeGreaterThan(0);
await session.sendMessage("/slow 8s");
await waitForNewSessionMessage(
apiClient,
task.session_id,
slowResponseMessageIdsBeforeSecond,
"Running slow response",
);
await session.cancelAgentButton().click();
await waitForSessionState(apiClient, {
taskId: task.id,
sessionId: task.session_id,
expectedState: "WAITING_FOR_INPUT",
message: "Waiting for the later accepted pause",
timeout: 30_000,
});
expect(await readSessionRuntimeIdentity(apiClient, task.id, task.session_id)).toEqual(
initialRuntimeIdentity,
);
expect(await countResumeBootMessages(apiClient, task.session_id)).toBe(
resumeBootsAfterFirstPause,
);
await session.sendMessage("/e2e:simple-message");
await session.expectChatResponseVisible("simple mock response", 1, { timeout: 30_000 });
expect(await readSessionRuntimeIdentity(apiClient, task.id, task.session_id)).toEqual(
initialRuntimeIdentity,
);
expect(await countResumeBootMessages(apiClient, task.session_id)).toBe(
resumeBootsAfterFirstPause,
);
} finally {
await apiClient.saveUserSettings({ prevent_auto_start_agent_on_open: false });
}
});
test("session startup keeps the composer editable and queues a submitted prompt", async ({
testPage,
apiClient,