From 86d8a5f186c7aa8ecc881bea962c6886157b4498 Mon Sep 17 00:00:00 2001 From: sam2tom Date: Thu, 17 Sep 2026 09:05:43 +0800 Subject: [PATCH 1/2] feat(web): add protocol-honest trace workbench --- apps/web/e2e/agents-lifecycle.spec.ts | 107 +++ apps/web/e2e/fixture-core.mjs | 12 +- .../src/features/sessions/SessionsView.tsx | 77 ++ .../sessions/trace/TraceView.test.tsx | 130 +++ .../src/features/sessions/trace/TraceView.tsx | 487 ++++++++++++ .../sessions/trace/trace-model.test.ts | 423 ++++++++++ .../features/sessions/trace/trace-model.ts | 738 ++++++++++++++++++ apps/web/src/style.css | 711 +++++++++++++++++ docs/protocol-coverage.md | 62 +- 9 files changed, 2735 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/features/sessions/trace/TraceView.test.tsx create mode 100644 apps/web/src/features/sessions/trace/TraceView.tsx create mode 100644 apps/web/src/features/sessions/trace/trace-model.test.ts create mode 100644 apps/web/src/features/sessions/trace/trace-model.ts diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts index 29ac6c2..899feb5 100644 --- a/apps/web/e2e/agents-lifecycle.spec.ts +++ b/apps/web/e2e/agents-lifecycle.spec.ts @@ -1285,6 +1285,113 @@ test("loads every Turn page, reconciles terminal events, and keeps failures besi await attachElementScreenshot(timeline, testInfo, "narrow-turn-timeline"); }); +test("presents an honest searchable Trace workbench without changing the conversation draft", async ({ page, request }, testInfo) => { + await resetFixture(request); + await controlFixture(request, { + turnsScenario: 1, + turnsPageSize: 2, + itemsScenario: 2, + }); + await page.goto("/"); + await expect(page.getByText("listening", { exact: true })).toBeVisible(); + + const viewTabs = page.getByRole("tablist", { name: "Session view" }); + const conversationTab = viewTabs.getByRole("tab", { name: "Conversation" }); + const traceTab = viewTabs.getByRole("tab", { name: "Trace" }); + const composer = page.getByLabel("Message the Agent"); + await expect(page.locator("#session-trace-panel")).toBeHidden(); + await composer.fill("Draft survives Trace inspection\nwith a second line"); + const sendsBefore = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + + await conversationTab.focus(); + await page.keyboard.press("ArrowRight"); + await expect(traceTab).toHaveAttribute("aria-selected", "true"); + const trace = page.getByRole("tabpanel", { name: "Trace" }); + await expect(trace).toBeVisible(); + await expect(trace).toContainText("Known Turn time"); + await expect(trace.getByText("7 observed Turns")).toHaveCount(0); + await expect(trace).toContainText("Turns"); + await expect(trace).toContainText("Tool calls"); + await expect(trace).toContainText("Equal-width sequence · not time-scaled"); + await expect(trace.locator(".trace-order-scroll")).toHaveCount(1); + await expect(trace).toContainText("Per-item timing is unavailable"); + await expect(trace).toContainText("Configured instructions"); + await expect(trace).toContainText("Completed Turn output remains in the conversation."); + await expect(trace).not.toContainText("TTFT"); + await expect(trace).not.toContainText("Throughput"); + + const search = trace.getByRole("searchbox", { name: "Search trace" }); + await search.fill("Persisted input failed"); + await expect(trace.locator(".trace-ledger-row")).toHaveCount(1); + await expect(trace).toContainText("Persisted input before the Turn failed."); + await search.fill(""); + + const patchRow = trace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); + await patchRow.click(); + const detail = page.getByRole("complementary", { name: "Trace item details" }); + await expect(detail).toBeVisible(); + await expect(detail.getByRole("button", { name: "Close trace details" })).toBeFocused(); + const previewTab = detail.getByRole("tab", { name: "Preview" }); + await expect(previewTab).toHaveAttribute("aria-controls", "session-trace-panel-detail-content"); + await previewTab.click(); + await expect(detail.getByRole("tabpanel", { name: "Preview" })).toHaveAttribute("tabindex", "0"); + const viewer = detail.getByRole("region", { name: "Parsar apply patch diff" }); + await expect(viewer).toContainText("3 files"); + await expect(viewer).toContainText("Completed"); + const desktopSplit = await Promise.all([ + trace.locator(".trace-ledger").boundingBox(), + detail.boundingBox(), + ]); + expect(desktopSplit[0]).not.toBeNull(); + expect(desktopSplit[1]).not.toBeNull(); + expect(desktopSplit[1]!.x).toBeGreaterThanOrEqual(desktopSplit[0]!.x + desktopSplit[0]!.width - 1); + await attachScreenshot(page, testInfo, "desktop-trace-detail"); + await page.keyboard.press("Escape"); + await expect(detail).toHaveCount(0); + await expect(patchRow).toBeFocused(); + + await patchRow.click(); + await search.fill("filter selected row out"); + await expect(patchRow).toBeHidden(); + await page.getByRole("button", { name: "Close trace details" }).click(); + await expect(search).toBeFocused(); + await search.fill(""); + + await conversationTab.click(); + await expect(composer).toBeVisible(); + await expect(composer).toHaveValue("Draft survives Trace inspection\nwith a second line"); + const sendsAfter = (await fixtureRequests(request)).filter( + (entry) => entry.method === "POST" && entry.path.endsWith("/events"), + ).length; + expect(sendsAfter).toBe(sendsBefore); + + await page.setViewportSize({ width: 390, height: 844 }); + await traceTab.click(); + const narrowTrace = page.getByRole("tabpanel", { name: "Trace" }); + const narrowPatchRow = narrowTrace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first(); + await narrowPatchRow.click(); + const narrowDetail = page.getByRole("complementary", { name: "Trace item details" }); + await expect(narrowDetail).toBeVisible(); + await expect(narrowTrace.locator(".trace-ledger")).toBeHidden(); + const widths = await narrowDetail.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + viewport: innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + left: box.left, + right: box.right, + }; + }); + expect(widths.document).toBeLessThanOrEqual(widths.viewport); + expect(widths.body).toBeLessThanOrEqual(widths.viewport); + expect(widths.left).toBeGreaterThanOrEqual(0); + expect(widths.right).toBeLessThanOrEqual(widths.viewport); + await attachScreenshot(page, testInfo, "narrow-trace-detail"); +}); + test("drops a delayed Turn page after switching Sessions", async ({ page, request }) => { await resetFixture(request); await controlFixture(request, { diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 577736b..2c3a6b1 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -659,11 +659,13 @@ const server = http.createServer(async (request, response) => { } const items = sessionId !== "session_snapshot" ? [] - : state.controls.itemsScenario - ? patchItems() - : state.controls.turnsScenario - ? observableTurnItems() - : []; + : state.controls.itemsScenario === 2 + ? [...observableTurnItems(), ...patchItems()] + : state.controls.itemsScenario + ? patchItems() + : state.controls.turnsScenario + ? observableTurnItems() + : []; return sendJson(response, page(items)); } diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx index 4d7e91f..068a3fc 100644 --- a/apps/web/src/features/sessions/SessionsView.tsx +++ b/apps/web/src/features/sessions/SessionsView.tsx @@ -38,11 +38,13 @@ import { } from "./environment/EnvironmentPanel"; import type { EnvironmentObservation } from "./environment/environment-state"; import { ThreadItems } from "./items/ItemRenderers"; +import { TraceView } from "./trace/TraceView"; import { TurnTimeline, type TurnTimelineLoadState } from "./turns/TurnTimeline"; import { SessionActionsDialog } from "./actions/SessionActionsDialog"; export type StreamState = "idle" | "connecting" | "listening" | "recovering" | "failed"; export type SessionDetailState = "idle" | "loading" | "ready" | "failed"; +type SessionView = "conversation" | "trace"; interface SessionsViewProps { agents: SavedAgent[]; @@ -329,6 +331,7 @@ export function SessionsView({ : "Create or load a saved Agent before starting a Session." : null; const [message, setMessage] = useState(""); + const [sessionView, setSessionView] = useState("conversation"); const [newSessionOpen, setNewSessionOpen] = useState(false); const [agentId, setAgentId] = useState(firstStartableAgent?.id ?? ""); const [actionSession, setActionSession] = useState(null); @@ -461,6 +464,25 @@ export function SessionsView({ (unsupportedActionCount > 0 || environmentConnections.length > 0 && functionActions.length === 0), ); + const onViewTabKeyDown = (event: KeyboardEvent) => { + const tabs = Array.from(event.currentTarget.parentElement?.querySelectorAll("[role=tab]") ?? []); + const index = tabs.indexOf(event.currentTarget); + if (index < 0) return; + const target = event.key === "ArrowRight" + ? tabs[(index + 1) % tabs.length] + : event.key === "ArrowLeft" + ? tabs[(index - 1 + tabs.length) % tabs.length] + : event.key === "Home" + ? tabs[0] + : event.key === "End" + ? tabs[tabs.length - 1] + : null; + if (!target) return; + event.preventDefault(); + target.click(); + target.focus(); + }; + return (