From bd202ad125129b436ca4c280502444da3fd07339 Mon Sep 17 00:00:00 2001 From: Devika Verma Date: Fri, 21 Aug 2026 15:17:14 -0700 Subject: [PATCH] e2e: seedSessionMessages seam + replayed-transcript specs Seeds rich history per session so the reopen path is testable. Covers replayed tool groups, filter counts, connector cards, tail-error Retry. --- surfaces/gui/e2e/README.md | 8 ++ surfaces/gui/e2e/fixtures.ts | 21 ++++++ surfaces/gui/e2e/seeded-history.spec.ts | 98 +++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 surfaces/gui/e2e/seeded-history.spec.ts diff --git a/surfaces/gui/e2e/README.md b/surfaces/gui/e2e/README.md index cf93c200..f7edac37 100644 --- a/surfaces/gui/e2e/README.md +++ b/surfaces/gui/e2e/README.md @@ -51,6 +51,14 @@ won't clash with a running `npm run dev` on 5173) and reuses it if already up. automation ("Daily AI News") with a running run — `POST .../run` appends a run, `PATCH`/`DELETE` toggle and remove. +- **Seeded transcripts**: every session's `GET /v1/sessions/{id}/messages` answers `[]`, so + reopening starts blank. `seedSessionMessages(page, sessionId, messages)` (exported from + fixtures) registers a later, winning route that stages full replayed history for one + session — tool_calls + `role:"tool"` results (wired by `tool_call_id`), `_display` + sidecars, `reasoning`, `notice` markers, connector `source` messages. Use it to assert + the reopen path (`itemsFromMessages`) — replayed step groups, connector cards, tail-error + Retry — which live echo-driving can't reach. See `seeded-history.spec.ts`. + ## Adding a spec ```ts diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 36eb4183..80c9c634 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -2099,6 +2099,27 @@ export async function mockApi(page: import("@playwright/test").Page) { }); } +/** Seed a replayed transcript for one session. The shared mock answers every + * GET /v1/sessions/{id}/messages with `[]`, so reopening a session always starts blank; + * this registers a LATER route (later routes win) that stages rich history for that one + * session — replayed tool calls with results, connector-sourced messages, notices, + * reasoning — so specs can assert the reopen path (itemsFromMessages) directly instead + * of driving every turn live through the fake agent. Call after the page has the mock + * (any time before the session is opened). */ +export async function seedSessionMessages( + page: Page, + sessionId: string, + messages: Record[], +): Promise { + await page.route(new RegExp(`/v1/sessions/${sessionId}/messages$`), (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ messages }), + }), + ); +} + // A `test` whose page has the API mocked before navigation. export const test = base.extend({ page: async ({ page }, use) => { diff --git a/surfaces/gui/e2e/seeded-history.spec.ts b/surfaces/gui/e2e/seeded-history.spec.ts new file mode 100644 index 00000000..0b71d738 --- /dev/null +++ b/surfaces/gui/e2e/seeded-history.spec.ts @@ -0,0 +1,98 @@ +// Seeded-transcript replay (the reopen path). Everything here renders from +// GET /v1/sessions/{id}/messages via itemsFromMessages — no live turns are driven — +// which is the one path the fake agent's echo scripting could never reach: replayed +// tool calls with results, privacy-filter counts, reasoning disclosures, persisted +// notices, and connector-sourced inbound messages. +import { expect } from "@playwright/test"; +import { test, seedSessionMessages } from "./fixtures"; + +const TS = 1755600000; // fixed epoch — replay must not depend on "now" + +const RICH_HISTORY = [ + { role: "user", content: "Audit the release branch", ts: TS }, + { + role: "assistant", + content: "", + tool_calls: [ + { id: "t1", function: { name: "run_shell", arguments: JSON.stringify({ command: "git log --oneline -5" }) } }, + { id: "t2", function: { name: "read_file", arguments: JSON.stringify({ path: "CHANGELOG.md" }) } }, + ], + }, + { role: "tool", tool_call_id: "t1", content: "abc123 release: cut 0.1.7" }, + { role: "tool", tool_call_id: "t2", content: "## 0.1.7 — fixes", _display: { hidden_by_filters: 3 } }, + { + role: "assistant", + content: "The branch is clean — **two checks** passed.", + reasoning: "Compared the log against the changelog; both entries line up.", + ts: TS + 40, + }, + { role: "notice", kind: "compacted", text: "Context compacted" }, + { role: "assistant", content: "Anything else before I file the summary?" }, +]; + +test("a reopened session replays rich history: tools, filters, reasoning, notices", async ({ + page, +}) => { + await seedSessionMessages(page, "pinned-cowork-1", RICH_HISTORY); + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + + // Plain items replay as they rendered live. + await expect(page.getByText("Audit the release branch")).toBeVisible(); + await expect(page.locator(".md strong", { hasText: "two checks" })).toBeVisible(); + await expect(page.getByText("Context compacted")).toBeVisible(); + + // The turn's tools fold into a collapsed step group; the filter count rides the summary. + const group = page.locator(".stepgroup").first(); + await expect(group).toContainText("2 steps"); + await expect(page.getByTestId("stepgroup-hidden")).toContainText("3 hidden"); + + // Expanding reveals the replayed rows with their results wired by tool_call_id. + await group.locator("summary").click(); + await expect(page.getByTestId("turn-step")).toHaveCount(2); + await expect(page.getByTestId("tool-hidden-count")).toBeVisible(); + + // Reasoning persists as the collapsed disclosure, not live "Thinking…". + await expect(page.getByTestId("thinking-toggle")).toContainText("Thought process"); +}); + +test("a connector-sourced message replays as its structured card", async ({ page }) => { + await seedSessionMessages(page, "pinned-cowork-1", [ + { + role: "user", + content: "[slack] Priya: Ship it when the checks are green", + source: { + connector: "slack", + kind: "channel", + channel_id: "C0REL", + channel_name: "#release", + sender_id: "U1", + sender_name: "Priya", + ts: TS, + text: "Ship it when the checks are green", + }, + }, + { role: "assistant", content: "Will do — watching the checks now." }, + ]); + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + + const card = page.locator(".connector-card[data-brand='slack']"); + await expect(card).toBeVisible(); + await expect(card).toContainText("Priya"); + await expect(card).toContainText("Ship it when the checks are green"); + // The framed model-facing content must NOT double-render as a plain bubble. + await expect(page.getByText("[slack] Priya:")).toHaveCount(0); +}); + +test("a replayed error notice at the tail offers Retry", async ({ page }) => { + await seedSessionMessages(page, "pinned-cowork-1", [ + { role: "user", content: "run the report", ts: TS }, + { role: "notice", kind: "error", text: "provider unavailable" }, + ]); + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + + await expect(page.getByText("Error: provider unavailable")).toBeVisible(); + await expect(page.getByTestId("notice-retry")).toBeVisible(); +});