diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index d486db139f4..52f2f12606c 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -743,6 +743,13 @@ const recoveryFakeCodex = resolve( runnerDiagnostics.push(chunk.slice(-4_096)); if (runnerDiagnostics.length > 32) runnerDiagnostics.shift(); }; + // The production resolver prefers installed Codex over PATH. Explicitly + // bind this disposable fixture so it can never invoke a real provider. + const codexCommandResolver = await vi.importActual<{ resolveCodexCommand: () => string }>( + "../../../../packages/paperclip-runner/src/drivers/codex/codex-command.js", + ); + const resolveFixtureCodex = vi.spyOn(codexCommandResolver, "resolveCodexCommand") + .mockReturnValue(join(bin, "codex")); process.env.PAPERCLIP_RUNNER_STATE_DIR = stateBase; try { await Promise.all( @@ -1227,6 +1234,7 @@ const recoveryFakeCodex = resolve( Promise.resolve().then(() => session.close({ reason: "Recovery fixture cleanup" })), )); const closeFailures = closed.filter((result) => result.status === "rejected"); + resolveFixtureCodex.mockRestore(); runnerPrpWebSocketInternals.resetForTests(); server.closeAllConnections(); await new Promise((done) => server.close(() => done())); diff --git a/tests/e2e/agent-chat-projects.spec.ts b/tests/e2e/agent-chat-projects.spec.ts index d4979449e37..c8fcdc2345e 100644 --- a/tests/e2e/agent-chat-projects.spec.ts +++ b/tests/e2e/agent-chat-projects.spec.ts @@ -124,12 +124,19 @@ test("split handoff commits relevant plans before execution and never creates ch const runs = await json( await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), ); - const run = runs.find( + const taskRuns = runs.filter( (run: any) => run.contextSnapshot?.issueId === task.id, ); - expect(run).toBeTruthy(); + expect(taskRuns.length).toBeGreaterThan(0); + const startedAt = taskRuns + .filter((run: any) => run.startedAt != null) + .map((run: any) => Date.parse(run.startedAt)); + // A newer queued/cancelled run need not have started. Preserve the + // invariant against the first actual execution, not list ordering. + expect(startedAt.length).toBeGreaterThan(0); + expect(startedAt.every(Number.isFinite)).toBe(true); expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual( - Date.parse(run.startedAt), + Math.min(...startedAt), ); } expect( diff --git a/tests/e2e/legacy-failure-continuation.spec.ts b/tests/e2e/legacy-failure-continuation.spec.ts index dc7875e862b..6645935cbab 100644 --- a/tests/e2e/legacy-failure-continuation.spec.ts +++ b/tests/e2e/legacy-failure-continuation.spec.ts @@ -99,9 +99,21 @@ for (const action of ["task_retry", "thread_retry", "inbox_retry", "message", "q } else if (action === "message") { await page.getByRole("textbox", { name: "editable markdown" }).fill("Please continue the pending follow-up."); await page.getByRole("button", { name: "Send", exact: true }).click(); + } else if (action === "inbox_retry") { + // Navigation can cancel the mutation before its request is sent. + const [response] = await Promise.all([ + page.waitForResponse((response) => { + const request = response.request(); + return request.method() === "POST" + && new URL(response.url()).pathname.endsWith(`/agents/${agent.id}/wakeup`) + && request.postDataJSON()?.failedRunId === sourceRunId; + }), + page.getByRole("button", { name: "Retry", exact: true }).click(), + ]); + expect(response.ok(), `${response.status()} ${await response.text()}`).toBe(true); + await page.goto(taskUrl); } else { await page.getByRole("button", { name: action === "thread_retry" ? "Try again" : "Retry", exact: true }).click(); - if (action === "inbox_retry") await page.goto(taskUrl); } await expect(page.getByText("Answered the pending follow-up once.", { exact: false })).toBeVisible({ timeout: 45_000 }); await expect(page.getByRole("status", { name: "Task recovery" })).toHaveCount(0); diff --git a/tests/e2e/new-task-picker-touch.spec.ts b/tests/e2e/new-task-picker-touch.spec.ts index b48d7084d67..6804125114e 100644 --- a/tests/e2e/new-task-picker-touch.spec.ts +++ b/tests/e2e/new-task-picker-touch.spec.ts @@ -21,13 +21,23 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB }).toBeLessThanOrEqual(page.viewportSize()!.height); expect((await picker.boundingBox())!.y).toBeGreaterThanOrEqual(0); const session = await page.context().newCDPSession(page); + const targetHandle = await last.elementHandle(); + expect(targetHandle).not.toBeNull(); try { expect(await list.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true); for (let attempt = 0; attempt < 20; attempt += 1) { - const bounds = (await list.boundingBox())!; - const target = (await last.boundingBox())!; + // Momentum can finish between protocol round trips. Observe geometry and + // scrollTop together so a stale target cannot trigger a swipe at the end. + const { bounds, target, before } = await list.evaluate((element, option) => { + const container = element.getBoundingClientRect(); + const item = option!.getBoundingClientRect(); + return { + bounds: { x: container.x, y: container.y, width: container.width, height: container.height }, + target: { y: item.y, height: item.height }, + before: element.scrollTop, + }; + }, targetHandle); if (target.y >= bounds.y && target.y + target.height <= bounds.y + bounds.height) break; - const before = await list.evaluate((element) => element.scrollTop); const x = bounds.x + bounds.width / 2; const y = bounds.y + bounds.height - 20; const distance = Math.min(180, bounds.height - 40); @@ -49,6 +59,7 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB expect(target.y).toBeGreaterThanOrEqual(bounds.y); expect(target.y + target.height).toBeLessThanOrEqual(bounds.y + bounds.height + 1); } finally { + await targetHandle?.dispose(); await session.detach(); } return last; @@ -56,6 +67,25 @@ async function swipeToLastOption(page: Page, picker: Locator, list = picker.getB test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); +test("touch scroll observation stays valid when momentum finishes between browser reads", async ({ page, browserName }) => { + test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol."); + await page.setContent(`
+
+ ${Array.from({ length: 12 }, (_, index) => `
Option ${index}
`).join("")} +
`); + const picker = page.getByRole("dialog"), list = picker.getByRole("listbox"), last = list.getByRole("option").last(); + const boundingBox = last.boundingBox.bind(last); + // Reproduce the observed compositor interleaving: a returned target rectangle + // is stale by the next scrollTop read because inertia reached the list end. + last.boundingBox = async (options) => { + const beforeMomentum = await boundingBox(options); + await list.evaluate((element) => { element.scrollTop = element.scrollHeight - element.clientHeight; }); + return beforeMomentum; + }; + await swipeToLastOption(page, picker, list, last); + await expect(picker).toBeVisible(); +}); + test("new-task assignee, model, and project sheets scroll by touch and retain the selected values", async ({ page, request, browserName }, testInfo) => { test.skip(browserName !== "chromium", "Native touch drags use Chromium's input protocol."); const company = await json(await request.post("/api/companies", { diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index 471589cf3cc..da005094f2d 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -270,6 +270,29 @@ async function getIssueRunLockState(board: APIRequestContext, issueId: string): }; } +async function waitForFixtureRun( + board: APIRequestContext, + agentId: string, + issueId: string, + runId: string, +) { + // Negative authorization cases deliberately invoke a non-participant. Leave + // their API rejection intact instead of waiting for a run they cannot own. + if ((await getIssueRunLockState(board, issueId)).assigneeAgentId !== agentId) return; + const waitBudgetMs = wakeupWaitBudgetMs(); + const deadline = Date.now() + waitBudgetMs; + let status: string | null = null; + do { + const run = await fetchVerifiedRun(board, runId, agentId, issueId); + expect(run, "Fixture run must belong to the acting agent and issue").not.toBeNull(); + status = run!.status; + if (status === "succeeded") return; + expect(["queued", "running"], `Fixture run ${runId} ended as ${status}`).toContain(status); + await new Promise((resolve) => setTimeout(resolve, WAKEUP_POLL_INTERVAL_MS)); + } while (Date.now() < deadline); + throw new Error(`Fixture run ${runId} did not succeed within ${waitBudgetMs}ms (last status: ${status})`); +} + async function retryAgentPatchWithCurrentLockOnConflict( board: APIRequestContext, agent: AgentAuth, @@ -286,6 +309,9 @@ async function retryAgentPatchWithCurrentLockOnConflict( if (issueRunLock.assigneeAgentId !== agent.agentId) return res; const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? fallbackRunId; + // A new lock can belong to a newly promoted fixture run, not the completed + // run we first invoked. Let that exact verified run settle before PATCH. + await waitForFixtureRun(board, agent.agentId, issueId, lockedRunId); res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { headers: { "X-Paperclip-Run-Id": lockedRunId }, data: patchData, @@ -325,6 +351,9 @@ async function agentPatch( }: { maxAttempts?: number; backoffMs?: number; maxBackoffMs?: number } = {}, ) { const runId = await invokeHeartbeat(board, agent.agentId, issueId); + // The fixture process only prints "done". Let its startup and cleanup settle + // before a simulated decision reassigns the issue and cancels that process. + await waitForFixtureRun(board, agent.agentId, issueId, runId); const patchWith = (patchRunId: string) => agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { headers: { "X-Paperclip-Run-Id": patchRunId }, @@ -339,6 +368,7 @@ async function agentPatch( // run-lock race — leave it for the caller to assert on. if (issueRunLock.assigneeAgentId !== agent.agentId) break; const retryRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId ?? runId; + await waitForFixtureRun(board, agent.agentId, issueId, retryRunId); res = await patchWith(retryRunId); } return res; @@ -353,6 +383,7 @@ async function agentCheckoutAndPatch( patchData: Record, ) { const runId = await invokeHeartbeat(board, agent.agentId, issueId); + await waitForFixtureRun(board, agent.agentId, issueId, runId); const directPatchRes = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { headers: { "X-Paperclip-Run-Id": runId }, data: patchData,