fix: retain session failures across recovery surfaces (#3669)
This commit is contained in:
@@ -63,11 +63,16 @@ async function assertUserVisibleFailure(page: Page, taskId: string, expected: Re
|
||||
await page.goto(`/t/${taskId}`);
|
||||
const session = new SessionPage(page);
|
||||
await session.waitForLoad(30_000);
|
||||
const launchError = session.activeChat().getByTestId("task-launch-error-entry");
|
||||
await expect(launchError).toBeVisible({ timeout: 30_000 });
|
||||
await launchError.getByRole("button", { name: "Show details" }).click();
|
||||
const details = launchError.getByTestId("task-launch-error-details");
|
||||
await expect(details).toBeVisible();
|
||||
const chat = session.activeChat();
|
||||
const recoveryRows = chat.locator(
|
||||
"[data-testid='session-recovery-action-message']:has([data-testid='recovery-resume-button'], [data-testid='recovery-fresh-button'], [data-testid='recovery-restart-button'])",
|
||||
);
|
||||
await expect(recoveryRows).toHaveCount(1, { timeout: 30_000 });
|
||||
const recovery = recoveryRows.first();
|
||||
await expect(recovery).toBeVisible();
|
||||
const details = recovery.locator("details");
|
||||
await expect(details).toHaveCount(1);
|
||||
await details.locator("summary").click();
|
||||
await expect(details).toContainText(expected);
|
||||
}
|
||||
|
||||
|
||||
@@ -71,8 +71,10 @@ test.describe("mobile PR watcher missing branch", () => {
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const chat = session.activeChat();
|
||||
const recovery = chat.getByTestId("task-launch-error-entry");
|
||||
const sharedError = testPage.getByTestId("task-shared-error");
|
||||
await expect(sharedError).toBeVisible({ timeout: 30_000 });
|
||||
await sharedError.getByTestId("task-shared-error-details").tap();
|
||||
const recovery = testPage.getByTestId("task-launch-error-entry");
|
||||
|
||||
await expect(recovery).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(recovery).toContainText("The workspace could not be prepared for this launch.");
|
||||
|
||||
@@ -92,9 +92,11 @@ test.describe("PR watcher missing branch", () => {
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
// --- Assert the bounded launch-error card appears once in chat ---
|
||||
const chat = session.activeChat();
|
||||
const recovery = chat.getByTestId("task-launch-error-entry");
|
||||
// --- Assert the task-owned error strip and its focused recovery panel ---
|
||||
const sharedError = testPage.getByTestId("task-shared-error");
|
||||
await expect(sharedError).toBeVisible({ timeout: 30_000 });
|
||||
await sharedError.getByTestId("task-shared-error-details").click();
|
||||
const recovery = testPage.getByTestId("task-launch-error-entry");
|
||||
await expect(recovery).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(recovery).toContainText("The workspace could not be prepared for this launch.");
|
||||
await expect(recovery).not.toContainText(prBranch);
|
||||
|
||||
@@ -106,7 +106,7 @@ test.describe("mobile: delayed resume cancellation", () => {
|
||||
test.describe("mobile: failed resume recovery", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("failed saved-session load returns a usable recovery card", async ({
|
||||
test("failed saved-session load retains a usable recovery entry", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -133,14 +133,15 @@ test.describe("mobile: failed resume recovery", () => {
|
||||
await fixture.session.recoveryResumeButton().tap();
|
||||
await expect(fixture.session.recoveryResumeButton()).toBeVisible();
|
||||
// The resume endpoint waits through provider startup before it returns;
|
||||
// keep the card usable throughout that failed-load path and ensure the
|
||||
// old permanently pending label never replaces the recovery action.
|
||||
// keep the persisted recovery entry usable throughout that failed-load
|
||||
// path and ensure the old permanently pending label never replaces it.
|
||||
await expect(testPage.getByText(/Resume session requested/i)).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(fixture.session.recoveryResumeButton()).toBeVisible({ timeout: 90_000 });
|
||||
await expect(
|
||||
fixture.session.activeChat().getByTestId("session-bootstrap-recovery-card"),
|
||||
).toBeVisible({ timeout: 90_000 });
|
||||
).toHaveCount(0);
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile failed resume recovery");
|
||||
} finally {
|
||||
await apiClient.deleteAgentProfile(profile.id, true).catch(() => undefined);
|
||||
|
||||
@@ -644,7 +644,10 @@ test.describe("Task creation from GitHub URL", () => {
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const launchError = session.activeChat().getByTestId("task-launch-error-entry");
|
||||
const sharedError = testPage.getByTestId("task-shared-error");
|
||||
await expect(sharedError).toBeVisible({ timeout: 30_000 });
|
||||
await sharedError.getByTestId("task-shared-error-details").click();
|
||||
const launchError = testPage.getByTestId("task-launch-error-entry");
|
||||
await expect(launchError).toBeVisible({ timeout: 30_000 });
|
||||
await expect(launchError).toContainText(/launch needs attention/i);
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ import { readTranscriptScrollState, setTranscriptScrollTop } from "../../helpers
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
type LaunchError = {
|
||||
scope?: "session" | "task";
|
||||
session_id?: string;
|
||||
task_repository_id?: string;
|
||||
stamp?: string;
|
||||
category?: string;
|
||||
recovery_actions?: string[];
|
||||
preview?: string;
|
||||
details?: string;
|
||||
};
|
||||
|
||||
async function taskLaunchError(
|
||||
@@ -27,7 +29,20 @@ async function taskLaunchError(
|
||||
): Promise<LaunchError | null> {
|
||||
const { tasks } = await apiClient.listTasks(workspaceId);
|
||||
const task = tasks.find((candidate: { id: string }) => candidate.id === taskId);
|
||||
return (task?.status_summary?.active_error as LaunchError | null | undefined) ?? null;
|
||||
const summary = task?.status_summary as
|
||||
| {
|
||||
task_error?: LaunchError | null;
|
||||
active_error?: LaunchError | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const legacyTaskError = summary?.active_error;
|
||||
return (
|
||||
summary?.task_error ??
|
||||
(legacyTaskError && !legacyTaskError.session_id && legacyTaskError.scope !== "session"
|
||||
? legacyTaskError
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTaskLaunchError(
|
||||
@@ -147,12 +162,17 @@ test.describe("task launch failure recovery", () => {
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const sharedError = testPage.getByTestId("task-shared-error");
|
||||
await expect(sharedError).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sharedError).toContainText("The linked pull request is already closed or merged.");
|
||||
await testPage.getByTestId("task-shared-error-details").click();
|
||||
const card = testPage.getByTestId("task-launch-error-entry");
|
||||
await expect(card).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(card).toContainText("The task is linked to a closed pull request.");
|
||||
await expect(card).toHaveCount(1);
|
||||
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(testPage.getByTestId("task-shared-error")).toBeVisible();
|
||||
await testPage.getByTestId("task-shared-error-details").click();
|
||||
await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(1);
|
||||
await testPage.getByTestId("task-launch-mark_review_done-button").click();
|
||||
|
||||
@@ -178,7 +198,6 @@ test.describe("task launch failure recovery", () => {
|
||||
backend,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(150_000);
|
||||
|
||||
const workflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
`Missing base recovery ${Date.now()}`,
|
||||
@@ -231,8 +250,11 @@ test.describe("task launch failure recovery", () => {
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
|
||||
const sharedError = testPage.getByTestId("task-shared-error");
|
||||
await expect(sharedError).toBeVisible({ timeout: 30_000 });
|
||||
await testPage.getByTestId("task-shared-error-details").click();
|
||||
const card = testPage.getByTestId("task-launch-error-entry");
|
||||
await expect(card).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect(card).toContainText("The selected base branch is not available.");
|
||||
await expect(card).not.toContainText("branch-that-no-longer-exists");
|
||||
await expect(testPage.getByTestId("last-agent-error-notice")).toHaveCount(0);
|
||||
@@ -243,6 +265,8 @@ test.describe("task launch failure recovery", () => {
|
||||
restoreSeedRepositoryOrigin(seedData);
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(testPage.getByTestId("task-shared-error")).toBeVisible();
|
||||
await testPage.getByTestId("task-shared-error-details").click();
|
||||
await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(1);
|
||||
|
||||
await testPage.getByTestId("task-launch-pick_base_branch-button").click();
|
||||
@@ -353,7 +377,7 @@ test.describe("task launch failure recovery", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("renders one correlated bootstrap recovery card with safe details", async ({
|
||||
test("retains session failure after automatic recovery and output", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -381,7 +405,8 @@ test.describe("task launch failure recovery", () => {
|
||||
);
|
||||
await apiClient.seedAgentMessages(task.session_id, 40, "bootstrap history");
|
||||
|
||||
const occurredAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const failureStamp = "bootstrap-presentation-e2e";
|
||||
const failureCreatedAt = new Date().toISOString();
|
||||
const longDetails = Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) => `agent_bootstrap; diagnostic_line=${index + 1}; cause=permission_denied`,
|
||||
@@ -393,14 +418,15 @@ test.describe("task launch failure recovery", () => {
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: "The agent could not start.",
|
||||
occurred_at: occurredAt,
|
||||
occurred_at: failureCreatedAt,
|
||||
agent_execution_id: "bootstrap-execution-e2e",
|
||||
execution_id: "bootstrap-execution-e2e",
|
||||
phase: "bootstrap",
|
||||
attempt_id: "bootstrap-execution-e2e",
|
||||
code: "generic_launch_failure",
|
||||
details: longDetails,
|
||||
stamp: "bootstrap-presentation-e2e",
|
||||
scope: "session",
|
||||
stamp: failureStamp,
|
||||
causes: [
|
||||
{
|
||||
operation: "resume",
|
||||
@@ -411,31 +437,67 @@ test.describe("task launch failure recovery", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await apiClient.seedSessionMessage(task.session_id, {
|
||||
type: "status",
|
||||
content: "Agent startup failed: The agent could not start.",
|
||||
createdAt: failureCreatedAt,
|
||||
metadata: {
|
||||
recovery_actions: true,
|
||||
scope: "session",
|
||||
error_stamp: failureStamp,
|
||||
error_output: longDetails,
|
||||
actions: [
|
||||
{
|
||||
type: "ws_request",
|
||||
label: "Resume session",
|
||||
icon: "refresh",
|
||||
test_id: "recovery-resume-button",
|
||||
params: {
|
||||
method: "session.recover",
|
||||
payload: { task_id: task.id, session_id: task.session_id, action: "resume" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ws_request",
|
||||
label: "Start fresh session",
|
||||
icon: "player-play",
|
||||
test_id: "recovery-fresh-button",
|
||||
params: {
|
||||
method: "session.recover",
|
||||
payload: { task_id: task.id, session_id: task.session_id, action: "fresh_start" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const card = testPage.getByTestId("session-bootstrap-recovery-card");
|
||||
await expect(card).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("session-recovery-error")).toHaveCount(0);
|
||||
await expect(card).toContainText("Session startup needs attention");
|
||||
await expect(card).toContainText("Required contribution access was denied.");
|
||||
await expect(card).not.toContainText("remote-secret");
|
||||
const failureText = "Agent startup failed: The agent could not start.";
|
||||
const failureRows = testPage.locator("[id^='msg-']").filter({ hasText: failureText });
|
||||
await expect(failureRows).toHaveCount(1, { timeout: 30_000 });
|
||||
const failureRow = failureRows.first();
|
||||
await expect(failureRow).toContainText(failureText);
|
||||
await expect(testPage.getByTestId("task-shared-error")).toHaveCount(0);
|
||||
const transcript = session.activeChat().locator(".chat-message-list").first();
|
||||
await expect
|
||||
.poll(async () => (await readTranscriptScrollState(transcript)).scrollOwnerCount)
|
||||
.toBe(1);
|
||||
const initialScroll = await readTranscriptScrollState(transcript);
|
||||
expect(initialScroll.scrollHeight).toBeGreaterThan(initialScroll.clientHeight);
|
||||
expect(initialScroll.scrollTop).toBe(0);
|
||||
expect(initialScroll.scrollTop).toBeGreaterThan(0);
|
||||
await expect(session.activeChat().getByTestId("chat-input-area")).toBeVisible();
|
||||
|
||||
const details = card.getByTestId("session-bootstrap-recovery-details");
|
||||
await expect(details).not.toHaveAttribute("open");
|
||||
await details.getByText("Recovery details").click();
|
||||
await expect(details).toHaveAttribute("open", "");
|
||||
await expect(card).toContainText("The required contribution access was denied.");
|
||||
const details = failureRow.getByText("Technical details", { exact: true });
|
||||
await details.click();
|
||||
const detailsPanel = failureRow.locator("details");
|
||||
await expect(detailsPanel).toHaveAttribute("open", "");
|
||||
await expect(detailsPanel).toContainText(
|
||||
"agent_bootstrap; diagnostic_line=1; cause=permission_denied",
|
||||
);
|
||||
await expect(failureRow.getByTestId("recovery-resume-button")).toBeVisible();
|
||||
await expect(failureRow.getByTestId("recovery-fresh-button")).toBeVisible();
|
||||
const expandedScroll = await readTranscriptScrollState(transcript);
|
||||
expect(expandedScroll.scrollOwnerCount).toBe(1);
|
||||
expect(expandedScroll.scrollHeight).toBeGreaterThan(expandedScroll.clientHeight);
|
||||
@@ -445,56 +507,103 @@ test.describe("task launch failure recovery", () => {
|
||||
);
|
||||
expect(middleScrollTop).toBeGreaterThan(0);
|
||||
|
||||
const sameStampMetadata = {
|
||||
last_agent_error: {
|
||||
message: "The agent could not start.",
|
||||
occurred_at: occurredAt,
|
||||
agent_execution_id: "bootstrap-execution-e2e",
|
||||
execution_id: "bootstrap-execution-e2e",
|
||||
phase: "bootstrap",
|
||||
attempt_id: "bootstrap-execution-e2e",
|
||||
code: "generic_launch_failure",
|
||||
details: longDetails,
|
||||
stamp: "bootstrap-presentation-e2e",
|
||||
causes: [
|
||||
{
|
||||
operation: "resume",
|
||||
code: "permission_denied",
|
||||
detail: "The required contribution access was denied.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await apiClient.seedTaskSession(task.id, {
|
||||
state: "WAITING_FOR_INPUT",
|
||||
sessionId: task.session_id,
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
metadata: sameStampMetadata,
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
|
||||
.toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
|
||||
|
||||
await apiClient.seedTaskSession(task.id, {
|
||||
state: "WAITING_FOR_INPUT",
|
||||
sessionId: task.session_id,
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
metadata: {
|
||||
...sameStampMetadata,
|
||||
last_agent_error: {
|
||||
...sameStampMetadata.last_agent_error,
|
||||
stamp: "bootstrap-presentation-e2e-new",
|
||||
message: "The agent could not start.",
|
||||
occurred_at: failureCreatedAt,
|
||||
agent_execution_id: "bootstrap-execution-e2e",
|
||||
execution_id: "bootstrap-execution-e2e",
|
||||
phase: "bootstrap",
|
||||
attempt_id: "bootstrap-execution-e2e",
|
||||
code: "generic_launch_failure",
|
||||
details: longDetails,
|
||||
scope: "session",
|
||||
stamp: failureStamp,
|
||||
dismissed_at: new Date(Date.now() + 1_000).toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop).toBe(0);
|
||||
for (const testId of [
|
||||
"recovery-resume-button",
|
||||
"recovery-restore-workspace-button",
|
||||
"recovery-fresh-button",
|
||||
]) {
|
||||
await expect(card.getByTestId(testId)).toBeVisible();
|
||||
}
|
||||
await apiClient.seedSessionMessage(task.session_id, {
|
||||
type: "script_execution",
|
||||
content: "Agent resumed",
|
||||
createdAt: new Date(Date.now() + 2_000).toISOString(),
|
||||
metadata: {
|
||||
script_type: "agent_boot",
|
||||
is_resuming: true,
|
||||
status: "exited",
|
||||
exit_code: 0,
|
||||
},
|
||||
});
|
||||
await apiClient.seedSessionMessage(task.session_id, {
|
||||
type: "message",
|
||||
content: "Recovered agent output",
|
||||
createdAt: new Date(Date.now() + 3_000).toISOString(),
|
||||
});
|
||||
await expect(failureRow).toContainText(failureText);
|
||||
await expect(failureRow.getByTestId("recovery-resume-button")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
|
||||
.toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
|
||||
|
||||
const nextFailureStamp = "bootstrap-presentation-e2e-new";
|
||||
const nextFailureCreatedAt = new Date(Date.now() + 4_000).toISOString();
|
||||
await apiClient.seedTaskSession(task.id, {
|
||||
state: "WAITING_FOR_INPUT",
|
||||
sessionId: task.session_id,
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: "The agent could not start.",
|
||||
occurred_at: nextFailureCreatedAt,
|
||||
agent_execution_id: "bootstrap-execution-e2e-new",
|
||||
execution_id: "bootstrap-execution-e2e-new",
|
||||
phase: "bootstrap",
|
||||
attempt_id: "bootstrap-execution-e2e-new",
|
||||
code: "generic_launch_failure",
|
||||
details: longDetails,
|
||||
scope: "session",
|
||||
stamp: nextFailureStamp,
|
||||
},
|
||||
},
|
||||
});
|
||||
await apiClient.seedSessionMessage(task.session_id, {
|
||||
type: "status",
|
||||
content: failureText,
|
||||
createdAt: nextFailureCreatedAt,
|
||||
metadata: {
|
||||
recovery_actions: true,
|
||||
scope: "session",
|
||||
error_stamp: nextFailureStamp,
|
||||
error_output: longDetails,
|
||||
actions: [
|
||||
{
|
||||
type: "ws_request",
|
||||
label: "Resume session",
|
||||
icon: "refresh",
|
||||
test_id: "recovery-resume-button",
|
||||
params: {
|
||||
method: "session.recover",
|
||||
payload: { task_id: task.id, session_id: task.session_id, action: "resume" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(failureRows).toHaveCount(2);
|
||||
const currentFailureRow = failureRows.last();
|
||||
await expect(currentFailureRow.getByTestId("recovery-resume-button")).toBeVisible();
|
||||
await expect(failureRow.getByTestId("recovery-resume-button")).toHaveCount(0);
|
||||
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
const reloadedFailureRows = testPage.locator("[id^='msg-']").filter({ hasText: failureText });
|
||||
await expect(reloadedFailureRows).toHaveCount(2);
|
||||
await expect(reloadedFailureRows.first().getByTestId("recovery-resume-button")).toHaveCount(0);
|
||||
await expect(reloadedFailureRows.last().getByTestId("recovery-resume-button")).toBeVisible();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "bootstrap recovery presentation");
|
||||
|
||||
await testPage.screenshot({
|
||||
@@ -502,14 +611,8 @@ test.describe("task launch failure recovery", () => {
|
||||
fullPage: true,
|
||||
});
|
||||
await prCapture.screenshot("bootstrap-recovery-card-desktop", {
|
||||
caption: "Desktop bootstrap recovery card with labeled recovery details and actions.",
|
||||
caption: "Desktop transcript keeps recovered session failure history and current actions.",
|
||||
fullPage: true,
|
||||
});
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(testPage.getByTestId("session-bootstrap-recovery-card")).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(testPage.getByTestId("session-recovery-error")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,21 @@ import { waitForSessionDone } from "../../helpers/session";
|
||||
import { readTranscriptScrollState, setTranscriptScrollTop } from "../../helpers/transcript-scroll";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
async function taskLaunchError(apiClient: ApiClient, workspaceId: string, taskId: string) {
|
||||
const { tasks } = await apiClient.listTasks(workspaceId);
|
||||
const summary = tasks.find((candidate) => candidate.id === taskId)?.status_summary;
|
||||
const legacy = summary?.active_error;
|
||||
return (
|
||||
summary?.task_error ??
|
||||
(legacy && !legacy.session_id && legacy.scope !== "session" ? legacy : null)
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForLaunchError(apiClient: ApiClient, workspaceId: string, taskId: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { tasks } = await apiClient.listTasks(workspaceId);
|
||||
const error = tasks.find((candidate) => candidate.id === taskId)?.status_summary
|
||||
?.active_error;
|
||||
const error = await taskLaunchError(apiClient, workspaceId, taskId);
|
||||
return Boolean(error?.stamp && error.category === "base_branch_missing");
|
||||
},
|
||||
{ timeout: 60_000, message: "waiting for the mobile launch-error projection" },
|
||||
@@ -81,8 +89,11 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
await session.waitForLoad();
|
||||
await waitForLaunchError(apiClient, seedData.workspaceId, task.id);
|
||||
|
||||
const sharedError = testPage.getByTestId("task-shared-error");
|
||||
await expect(sharedError).toBeVisible({ timeout: 30_000 });
|
||||
await testPage.getByTestId("task-shared-error-details").tap();
|
||||
const card = testPage.getByTestId("task-launch-error-entry");
|
||||
await expect(card).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect(testPage.getByTestId("last-agent-error-notice")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("prepare-progress-panel")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("missing-branch-recovery")).toHaveCount(0);
|
||||
@@ -100,6 +111,8 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
restoreSeedRepositoryOrigin(seedData);
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
await expect(testPage.getByTestId("task-shared-error")).toBeVisible();
|
||||
await testPage.getByTestId("task-shared-error-details").tap();
|
||||
await testPage.getByTestId("task-launch-pick_base_branch-button").tap();
|
||||
await expect(testPage.getByTestId("task-launch-branch-picker-mobile")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
@@ -130,16 +143,10 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
})
|
||||
.toBe("main");
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
|
||||
return (
|
||||
tasks.find((candidate) => candidate.id === task.id)?.status_summary?.active_error ??
|
||||
null
|
||||
);
|
||||
},
|
||||
{ timeout: 60_000, message: "waiting for the mobile launch error to clear" },
|
||||
)
|
||||
.poll(() => taskLaunchError(apiClient, seedData.workspaceId, task.id), {
|
||||
timeout: 60_000,
|
||||
message: "waiting for the mobile launch error to clear",
|
||||
})
|
||||
.toBeNull();
|
||||
|
||||
expect(taskRepository.id).toBe((await apiClient.getTask(task.id)).repositories?.[0]?.id);
|
||||
@@ -209,19 +216,10 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
.toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { tasks } = await apiClient.listTasks(seedData.workspaceId);
|
||||
return (
|
||||
tasks.find((candidate) => candidate.id === task.id)?.status_summary?.active_error ??
|
||||
null
|
||||
);
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: "waiting for the mobile local-base launch error to remain clear",
|
||||
},
|
||||
)
|
||||
.poll(() => taskLaunchError(apiClient, seedData.workspaceId, task.id), {
|
||||
timeout: 30_000,
|
||||
message: "waiting for the mobile local-base launch error to remain clear",
|
||||
})
|
||||
.toBeNull();
|
||||
await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(0);
|
||||
|
||||
@@ -236,7 +234,7 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("renders the bootstrap recovery card with stacked touch controls", async ({
|
||||
test("retains session failure history with stacked touch controls", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -264,7 +262,8 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
);
|
||||
await apiClient.seedAgentMessages(task.session_id, 40, "mobile bootstrap history");
|
||||
|
||||
const occurredAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const failureStamp = "mobile-bootstrap-presentation-e2e";
|
||||
const failureCreatedAt = new Date().toISOString();
|
||||
const longDetails = Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) => `agent_bootstrap; diagnostic_line=${index + 1}; cause=permission_denied`,
|
||||
@@ -276,14 +275,15 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: "The agent could not start.",
|
||||
occurred_at: occurredAt,
|
||||
occurred_at: failureCreatedAt,
|
||||
agent_execution_id: "mobile-bootstrap-execution-e2e",
|
||||
execution_id: "mobile-bootstrap-execution-e2e",
|
||||
phase: "bootstrap",
|
||||
attempt_id: "mobile-bootstrap-execution-e2e",
|
||||
code: "generic_launch_failure",
|
||||
details: longDetails,
|
||||
stamp: "mobile-bootstrap-presentation-e2e",
|
||||
scope: "session",
|
||||
stamp: failureStamp,
|
||||
causes: [
|
||||
{
|
||||
operation: "resume",
|
||||
@@ -294,29 +294,59 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await apiClient.seedSessionMessage(task.session_id, {
|
||||
type: "status",
|
||||
content: "Agent startup failed: The agent could not start.",
|
||||
createdAt: failureCreatedAt,
|
||||
metadata: {
|
||||
recovery_actions: true,
|
||||
scope: "session",
|
||||
error_stamp: failureStamp,
|
||||
error_output: longDetails,
|
||||
actions: [
|
||||
{
|
||||
type: "ws_request",
|
||||
label: "Resume session",
|
||||
icon: "refresh",
|
||||
test_id: "recovery-resume-button",
|
||||
params: {
|
||||
method: "session.recover",
|
||||
payload: { task_id: task.id, session_id: task.session_id, action: "resume" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ws_request",
|
||||
label: "Start fresh session",
|
||||
icon: "player-play",
|
||||
test_id: "recovery-fresh-button",
|
||||
params: {
|
||||
method: "session.recover",
|
||||
payload: { task_id: task.id, session_id: task.session_id, action: "fresh_start" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
const card = testPage.getByTestId("session-bootstrap-recovery-card");
|
||||
await expect(card).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(testPage.getByTestId("task-launch-error-entry")).toHaveCount(0);
|
||||
await expect(testPage.getByTestId("session-recovery-error")).toHaveCount(0);
|
||||
const failureText = "Agent startup failed: The agent could not start.";
|
||||
const failureRows = testPage.locator("[id^='msg-']").filter({ hasText: failureText });
|
||||
await expect(failureRows).toHaveCount(1, { timeout: 30_000 });
|
||||
const failureRow = failureRows.first();
|
||||
await expect(testPage.getByTestId("task-shared-error")).toHaveCount(0);
|
||||
const transcript = session.activeChat().locator(".chat-message-list").first();
|
||||
await expect
|
||||
.poll(async () => (await readTranscriptScrollState(transcript)).scrollOwnerCount)
|
||||
.toBe(1);
|
||||
const initialScroll = await readTranscriptScrollState(transcript);
|
||||
expect(initialScroll.scrollHeight).toBeGreaterThan(initialScroll.clientHeight);
|
||||
expect(initialScroll.scrollTop).toBe(0);
|
||||
expect(initialScroll.scrollTop).toBeGreaterThan(0);
|
||||
await expect(session.activeChat().getByTestId("chat-input-area")).toBeVisible();
|
||||
|
||||
for (const testId of [
|
||||
"recovery-resume-button",
|
||||
"recovery-restore-workspace-button",
|
||||
"recovery-fresh-button",
|
||||
]) {
|
||||
const button = card.getByTestId(testId);
|
||||
for (const testId of ["recovery-resume-button", "recovery-fresh-button"]) {
|
||||
const button = failureRow.getByTestId(testId);
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toBeInViewport();
|
||||
const box = await button.boundingBox();
|
||||
@@ -324,11 +354,13 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
const details = card.getByTestId("session-bootstrap-recovery-details");
|
||||
await expect(details).not.toHaveAttribute("open");
|
||||
await details.getByText("Recovery details").tap();
|
||||
await expect(details).toHaveAttribute("open", "");
|
||||
await expect(card).toContainText("The required contribution access was denied.");
|
||||
const details = failureRow.getByText("Technical details", { exact: true });
|
||||
await details.tap();
|
||||
const detailsPanel = failureRow.locator("details");
|
||||
await expect(detailsPanel).toHaveAttribute("open", "");
|
||||
await expect(detailsPanel).toContainText(
|
||||
"agent_bootstrap; diagnostic_line=1; cause=permission_denied",
|
||||
);
|
||||
const expandedScroll = await readTranscriptScrollState(transcript);
|
||||
expect(expandedScroll.scrollOwnerCount).toBe(1);
|
||||
expect(expandedScroll.scrollHeight).toBeGreaterThan(expandedScroll.clientHeight);
|
||||
@@ -338,49 +370,103 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
);
|
||||
expect(middleScrollTop).toBeGreaterThan(0);
|
||||
|
||||
const sameStampMetadata = {
|
||||
last_agent_error: {
|
||||
message: "The agent could not start.",
|
||||
occurred_at: occurredAt,
|
||||
agent_execution_id: "mobile-bootstrap-execution-e2e",
|
||||
execution_id: "mobile-bootstrap-execution-e2e",
|
||||
phase: "bootstrap",
|
||||
attempt_id: "mobile-bootstrap-execution-e2e",
|
||||
code: "generic_launch_failure",
|
||||
details: longDetails,
|
||||
stamp: "mobile-bootstrap-presentation-e2e",
|
||||
causes: [
|
||||
{
|
||||
operation: "resume",
|
||||
code: "permission_denied",
|
||||
detail: "The required contribution access was denied.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await apiClient.seedTaskSession(task.id, {
|
||||
state: "WAITING_FOR_INPUT",
|
||||
sessionId: task.session_id,
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
metadata: sameStampMetadata,
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
|
||||
.toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
|
||||
|
||||
await apiClient.seedTaskSession(task.id, {
|
||||
state: "WAITING_FOR_INPUT",
|
||||
sessionId: task.session_id,
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
metadata: {
|
||||
...sameStampMetadata,
|
||||
last_agent_error: {
|
||||
...sameStampMetadata.last_agent_error,
|
||||
stamp: "mobile-bootstrap-presentation-e2e-new",
|
||||
message: "The agent could not start.",
|
||||
occurred_at: failureCreatedAt,
|
||||
agent_execution_id: "mobile-bootstrap-execution-e2e",
|
||||
execution_id: "mobile-bootstrap-execution-e2e",
|
||||
phase: "bootstrap",
|
||||
attempt_id: "mobile-bootstrap-execution-e2e",
|
||||
code: "generic_launch_failure",
|
||||
details: longDetails,
|
||||
scope: "session",
|
||||
stamp: failureStamp,
|
||||
dismissed_at: new Date(Date.now() + 1_000).toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop).toBe(0);
|
||||
await apiClient.seedSessionMessage(task.session_id, {
|
||||
type: "script_execution",
|
||||
content: "Agent resumed",
|
||||
createdAt: new Date(Date.now() + 2_000).toISOString(),
|
||||
metadata: {
|
||||
script_type: "agent_boot",
|
||||
is_resuming: true,
|
||||
status: "exited",
|
||||
exit_code: 0,
|
||||
},
|
||||
});
|
||||
await apiClient.seedSessionMessage(task.session_id, {
|
||||
type: "message",
|
||||
content: "Recovered agent output",
|
||||
createdAt: new Date(Date.now() + 3_000).toISOString(),
|
||||
});
|
||||
await expect(failureRow).toContainText(failureText);
|
||||
await expect(failureRow.getByTestId("recovery-resume-button")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () => (await readTranscriptScrollState(transcript)).scrollTop)
|
||||
.toBeGreaterThanOrEqual(Math.max(0, middleScrollTop - 2));
|
||||
|
||||
const nextFailureStamp = "mobile-bootstrap-presentation-e2e-new";
|
||||
const nextFailureCreatedAt = new Date(Date.now() + 4_000).toISOString();
|
||||
await apiClient.seedTaskSession(task.id, {
|
||||
state: "WAITING_FOR_INPUT",
|
||||
sessionId: task.session_id,
|
||||
agentProfileId: seedData.agentProfileId,
|
||||
metadata: {
|
||||
last_agent_error: {
|
||||
message: "The agent could not start.",
|
||||
occurred_at: nextFailureCreatedAt,
|
||||
agent_execution_id: "mobile-bootstrap-execution-e2e-new",
|
||||
execution_id: "mobile-bootstrap-execution-e2e-new",
|
||||
phase: "bootstrap",
|
||||
attempt_id: "mobile-bootstrap-execution-e2e-new",
|
||||
code: "generic_launch_failure",
|
||||
details: longDetails,
|
||||
scope: "session",
|
||||
stamp: nextFailureStamp,
|
||||
},
|
||||
},
|
||||
});
|
||||
await apiClient.seedSessionMessage(task.session_id, {
|
||||
type: "status",
|
||||
content: failureText,
|
||||
createdAt: nextFailureCreatedAt,
|
||||
metadata: {
|
||||
recovery_actions: true,
|
||||
scope: "session",
|
||||
error_stamp: nextFailureStamp,
|
||||
error_output: longDetails,
|
||||
actions: [
|
||||
{
|
||||
type: "ws_request",
|
||||
label: "Resume session",
|
||||
icon: "refresh",
|
||||
test_id: "recovery-resume-button",
|
||||
params: {
|
||||
method: "session.recover",
|
||||
payload: { task_id: task.id, session_id: task.session_id, action: "resume" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await expect(failureRows).toHaveCount(2);
|
||||
const currentFailureRow = failureRows.last();
|
||||
await expect(currentFailureRow.getByTestId("recovery-resume-button")).toBeVisible();
|
||||
await expect(failureRow.getByTestId("recovery-resume-button")).toHaveCount(0);
|
||||
|
||||
await testPage.reload();
|
||||
await session.waitForLoad();
|
||||
const reloadedFailureRows = testPage.locator("[id^='msg-']").filter({ hasText: failureText });
|
||||
await expect(reloadedFailureRows).toHaveCount(2);
|
||||
await expect(reloadedFailureRows.first().getByTestId("recovery-resume-button")).toHaveCount(0);
|
||||
await expect(reloadedFailureRows.last().getByTestId("recovery-resume-button")).toBeVisible();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile bootstrap recovery presentation");
|
||||
|
||||
await testPage.screenshot({
|
||||
@@ -388,7 +474,7 @@ test.describe("mobile task launch failure recovery", () => {
|
||||
fullPage: true,
|
||||
});
|
||||
await prCapture.screenshot("bootstrap-recovery-card-mobile", {
|
||||
caption: "Mobile bootstrap recovery card with stacked touch-sized actions.",
|
||||
caption: "Mobile transcript keeps recovered session failure history and current actions.",
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user