diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 35822b09..90e6dd49 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -1677,29 +1677,34 @@ function workbenchTurnPayload(traceId: string): JsonRecord { function tracePayload(traceId: string, url: URL): JsonRecord { const turn = turnPayload(traceId); const events = Array.isArray(turn.events) ? turn.events as JsonRecord[] : []; - const afterSeq = traceAfterSeq(url); + const afterProjectedSeq = traceAfterProjectedSeq(url); const requestedLimit = Number(url.searchParams.get("limit") ?? events.length); - const limit = requestedLimit || events.length || 100; - const filtered = afterSeq > 0 ? events.filter((event) => Number(event.seq ?? 0) > afterSeq) : events; - const page = filtered.slice(0, Math.max(1, Number.isFinite(limit) ? limit : 100)); - const firstSeq = page.length > 0 ? Number(page[0]?.seq ?? 0) : null; - const lastSeq = page.length > 0 ? Number(page.at(-1)?.seq ?? 0) : null; + const limit = Math.max(1, Number.isFinite(requestedLimit) ? Math.trunc(requestedLimit) : events.length || 100); + const indexedEvents = events.map((event, index) => { + const projectedSeq = nonNegativeInteger(event.projectedSeq ?? event.seq, index + 1); + return { event: { ...event, seq: projectedSeq, projectedSeq }, projectedSeq }; + }); + const filtered = afterProjectedSeq > 0 ? indexedEvents.filter((item) => item.projectedSeq > afterProjectedSeq) : indexedEvents; + const pageRecords = filtered.slice(0, limit); + const page = pageRecords.map((item) => item.event); + const firstProjectedSeq = pageRecords.length > 0 ? pageRecords[0]?.projectedSeq ?? null : null; + const lastProjectedSeq = pageRecords.length > 0 ? pageRecords.at(-1)?.projectedSeq ?? null : null; const hasMore = filtered.length > page.length; - return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextSinceSeq: lastSeq, nextSeq: page.length > 0 ? lastSeq : afterSeq, range: { afterSeq, fromSeq: firstSeq, toSeq: lastSeq, limit: Math.max(1, Number.isFinite(limit) ? limit : 100), returned: page.length, total: events.length } }; + return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextProjectedSeq: page.length > 0 ? lastProjectedSeq : afterProjectedSeq, nextCursor: hasMore && lastProjectedSeq ? `projected:${lastProjectedSeq}` : null, range: { afterProjectedSeq, fromProjectedSeq: firstProjectedSeq, toProjectedSeq: lastProjectedSeq, limit, returned: page.length, total: events.length } }; } -function traceAfterSeq(url: URL): number { +function traceAfterProjectedSeq(url: URL): number { const cursor = url.searchParams.get("cursor") ?? ""; - if (cursor.startsWith("seq:")) return Math.max(0, Number.parseInt(cursor.slice(4), 10) || 0); - return Math.max(0, Number.parseInt(url.searchParams.get("sinceSeq") ?? "0", 10) || 0); + if (cursor.startsWith("projected:")) return nonNegativeInteger(cursor.slice("projected:".length), 0); + return nonNegativeInteger(url.searchParams.get("afterProjectedSeq"), 0); } function workbenchTracePayload(traceId: string, url: URL): JsonRecord { const payload = tracePayload(traceId, url); if (state.scenarioId === "tool-completed-projection-running" && traceId === "trc_running") { - return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: "completed", events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, projectionStatus: "projecting", terminalEvidence: null, finalResponse: null, traceSummary: payload.traceSummary, retention: payload.retention }; + return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: "completed", events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextProjectedSeq: payload.nextProjectedSeq, nextCursor: payload.nextCursor, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, projectionStatus: "projecting", terminalEvidence: null, finalResponse: null, traceSummary: payload.traceSummary, retention: payload.retention }; } - return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention, ...projectionEnvelope(payload) }; + return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextProjectedSeq: payload.nextProjectedSeq, nextCursor: payload.nextCursor, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention, ...projectionEnvelope(payload) }; } function projectionEnvelope(payload: JsonRecord): JsonRecord { @@ -1915,3 +1920,8 @@ function positiveInteger(value: unknown, fallback: number): number { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } + +function nonNegativeInteger(value: unknown, fallback: number): number { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : Math.max(0, Math.trunc(fallback)); +} diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projector-resume.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projector-resume.spec.ts index f620ea7f..3011f8a2 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projector-resume.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projector-resume.spec.ts @@ -36,14 +36,25 @@ test.describe("durable projector resume", () => { expect(traceBody.projectionStatus).toBe("caught-up"); expect(traceBody.projectionHealth).toBe("caught-up"); expect(traceBody.events.map((event: { seq?: number }) => event.seq)).toEqual([1, 2, 3, 4, 5, 6, 7]); - expect(traceBody.range).toMatchObject({ afterSeq: 0, fromSeq: 1, toSeq: 7, returned: 7, total: 7 }); + expect(traceBody.range).toMatchObject({ afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 7, returned: 7, total: 7 }); + expect(traceBody.nextProjectedSeq).toBe(7); + expect(traceBody).not.toHaveProperty("nextSeq"); + expect(traceBody).not.toHaveProperty("nextSinceSeq"); + expect(traceBody.range).not.toHaveProperty("afterSeq"); + expect(traceBody.range).not.toHaveProperty("fromSeq"); + expect(traceBody.range).not.toHaveProperty("toSeq"); - const emptyTracePage = await page.request.get(`/v1/workbench/traces/${traceId}/events?cursor=seq:999999&limit=5`); + const emptyTracePage = await page.request.get(`/v1/workbench/traces/${traceId}/events?cursor=projected:999999&limit=5`); expect(emptyTracePage.status()).toBe(200); const emptyTraceBody = await emptyTracePage.json(); expect(emptyTraceBody.events).toHaveLength(0); - expect(emptyTraceBody.range).toMatchObject({ afterSeq: 999999, fromSeq: null, toSeq: null, returned: 0, total: 7 }); - expect(emptyTraceBody.nextSeq).toBe(999999); + expect(emptyTraceBody.range).toMatchObject({ afterProjectedSeq: 999999, fromProjectedSeq: null, toProjectedSeq: null, returned: 0, total: 7 }); + expect(emptyTraceBody.nextProjectedSeq).toBe(999999); + expect(emptyTraceBody).not.toHaveProperty("nextSeq"); + expect(emptyTraceBody).not.toHaveProperty("nextSinceSeq"); + expect(emptyTraceBody.range).not.toHaveProperty("afterSeq"); + expect(emptyTraceBody.range).not.toHaveProperty("fromSeq"); + expect(emptyTraceBody.range).not.toHaveProperty("toSeq"); const finalCard = page.locator(`${selectors.messageCard}[data-role="agent"]`).filter({ hasText: "fake projector resume completed from durable checkpoint" }); await expect(finalCard).toHaveAttribute("data-status", "completed"); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/workbench-golden.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/workbench-golden.spec.ts new file mode 100644 index 00000000..c4c886b5 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/workbench-golden.spec.ts @@ -0,0 +1,129 @@ +import type { Locator, Page } from "@playwright/test"; + +import { expect, fakeServerState, gotoWorkbench, saveScreenshot, test } from "../fixtures/test"; +import { selectors, sessionTab } from "../fixtures/selectors"; + +type TracePagePayload = { + events?: Array<{ seq?: number; projectedSeq?: number; terminal?: boolean; status?: string; label?: string }>; + eventCount?: number; + hasMore?: boolean; + nextProjectedSeq?: number | null; + nextCursor?: string | null; + range?: Record; + [key: string]: unknown; +}; + +test.describe("Workbench golden e2e chain", () => { + test.use({ scenarioId: "live-rest-backfill-without-terminal-sse" }); + + test("new session reaches sealed final response, survives reload and archives by authority", async ({ page }, testInfo) => { + await gotoWorkbench(page); + + await page.locator(selectors.sessionCreate).click(); + const activeTab = page.locator('.session-tab[data-active="true"]').first(); + await expect(activeTab).toHaveAttribute("data-session-id", /^ses_/u); + const sessionId = await activeTab.getAttribute("data-session-id"); + expect(sessionId).toBeTruthy(); + if (!sessionId) throw new Error("expected active session id"); + + await page.locator(selectors.commandInput).fill("hi"); + await expect(page.locator(selectors.commandSend)).toBeEnabled(); + await page.locator(selectors.commandSend).click(); + + const agentCard = page.locator(`${selectors.messageCard}[data-role="agent"]`).last(); + await expect(page.locator(sessionTab(sessionId))).toHaveAttribute("data-running", "true"); + await expect(agentCard).toHaveAttribute("data-status", "running"); + await expect(agentCard.locator(selectors.traceTimeline)).toHaveAttribute("data-status", "running"); + await saveScreenshot(page, testInfo, "golden-running"); + + await expect(agentCard).toHaveAttribute("data-status", "completed", { timeout: 12_000 }); + await expect(agentCard).toContainText("fake AgentRun completed after REST backfill without terminal SSE."); + await expect(page.locator(sessionTab(sessionId))).toHaveAttribute("data-running", "false"); + + const trace = agentCard.locator(selectors.traceTimeline); + await expect(trace).toHaveAttribute("data-status", "completed"); + await openTrace(trace); + await expect(trace.locator(`${selectors.traceRow}[data-terminal="true"]`)).toBeVisible(); + + const tracePage = await fetchTracePage(page, "trc_live_backfill", "afterProjectedSeq=4&limit=10"); + expect(projectedSeqs(tracePage)).toEqual([5, 6, 7]); + expect(tracePage.range).toMatchObject({ afterProjectedSeq: 4, fromProjectedSeq: 5, toProjectedSeq: 7, returned: 3, total: 7 }); + expect(tracePage.nextProjectedSeq).toBe(7); + expectNoLegacyTraceCursorFields(tracePage); + expect(tracePage.events?.at(-1)?.terminal).toBe(true); + await saveScreenshot(page, testInfo, "golden-completed"); + + await page.reload(); + await expect(page.locator(sessionTab(sessionId))).toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last()).toContainText("fake AgentRun completed after REST backfill without terminal SSE."); + await saveScreenshot(page, testInfo, "golden-reloaded"); + + await page.locator(sessionTab("ses_completed")).click(); + await expect(page.locator(sessionTab("ses_completed"))).toHaveAttribute("data-active", "true"); + await page.locator(sessionTab(sessionId)).click(); + await expect(page.locator(sessionTab(sessionId))).toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last()).toContainText("fake AgentRun completed after REST backfill without terminal SSE."); + + await page.locator(selectors.sessionDelete).click(); + await expect(page.locator(sessionTab(sessionId))).toHaveCount(0); + const detail = await page.request.get(`/v1/workbench/sessions/${encodeURIComponent(sessionId)}`); + expect(detail.status()).toBe(200); + const detailBody = await detail.json() as { session?: { status?: string } }; + expect(detailBody.session?.status).toBe("archived"); + + const state = await fakeServerState(page) as { legacyRequestLedger?: unknown[]; chatRequests?: unknown[] }; + expect(state.legacyRequestLedger).toEqual([]); + expect(state.chatRequests).toHaveLength(1); + }); +}); + +test.describe("long trace projected pagination", () => { + test.use({ scenarioId: "scroll-follow-long-trace" }); + + test("long trace fixture pages only by projected cursor", async ({ page }, testInfo) => { + await gotoWorkbench(page, "/workbench/sessions/ses_scroll_follow"); + + const firstPage = await fetchTracePage(page, "trc_scroll_follow", "afterProjectedSeq=0&limit=25"); + expect(projectedSeqs(firstPage)).toEqual(Array.from({ length: 25 }, (_, index) => index + 1)); + expect(firstPage.range).toMatchObject({ afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 25, returned: 25, total: 90 }); + expect(firstPage.nextProjectedSeq).toBe(25); + expect(firstPage.nextCursor).toBe("projected:25"); + expectNoLegacyTraceCursorFields(firstPage); + + const laterPage = await fetchTracePage(page, "trc_scroll_follow", "cursor=projected:70&limit=10"); + expect(projectedSeqs(laterPage)).toEqual(Array.from({ length: 10 }, (_, index) => index + 71)); + expect(laterPage.range).toMatchObject({ afterProjectedSeq: 70, fromProjectedSeq: 71, toProjectedSeq: 80, returned: 10, total: 90 }); + expect(laterPage.nextProjectedSeq).toBe(80); + expect(laterPage.nextCursor).toBe("projected:80"); + expectNoLegacyTraceCursorFields(laterPage); + + await expect(page.locator(`${selectors.traceTimeline}[data-status="running"]`).last()).toBeVisible(); + await saveScreenshot(page, testInfo, "long-trace-projected-pagination"); + }); +}); + +async function fetchTracePage(page: Page, traceId: string, query: string): Promise { + const response = await page.request.get(`/v1/workbench/traces/${encodeURIComponent(traceId)}/events?${query}`); + expect(response.status()).toBe(200); + return await response.json() as TracePagePayload; +} + +function projectedSeqs(payload: TracePagePayload): number[] { + return (payload.events ?? []).map((event) => Number(event.projectedSeq ?? event.seq)); +} + +function expectNoLegacyTraceCursorFields(payload: TracePagePayload): void { + expect(payload).not.toHaveProperty("nextSeq"); + expect(payload).not.toHaveProperty("nextSinceSeq"); + expect(payload).not.toHaveProperty("sinceSeq"); + expect(payload.range).not.toHaveProperty("afterSeq"); + expect(payload.range).not.toHaveProperty("fromSeq"); + expect(payload.range).not.toHaveProperty("toSeq"); + expect(payload.range).not.toHaveProperty("sinceSeq"); +} + +async function openTrace(trace: Locator): Promise { + const details = trace.locator("details.trace-disclosure"); + const open = await details.evaluate((element) => (element as HTMLDetailsElement).open).catch(() => false); + if (!open) await trace.locator("summary.trace-disclosure-summary").click(); +}