diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index d38f9b31..ff34f15f 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -623,6 +623,103 @@ test("workbench read model does not expose trace-only memory without visible ses } }); +test("workbench trace events reports metadata gap when turn projection is visible", async () => { + const traceStore = createCodeAgentTraceStore(); + const traceId = "trc_workbench_trace_metadata_gap"; + const session = { + id: "ses_workbench_trace_metadata_gap", + projectId: "prj_hwpod_workbench", + agentId: "hwlab-code-agent", + status: "completed", + ownerUserId: ACTOR.id, + conversationId: "cnv_workbench_trace_metadata_gap", + threadId: "thread-workbench-trace-metadata-gap", + lastTraceId: traceId, + updatedAt: "2026-06-20T13:00:00.000Z", + session: { sessionStatus: "completed", lastTraceId: traceId, messages: [{ role: "user", text: "metadata gap", traceId }, { role: "agent", text: "OK", traceId }] } + }; + const accessController = { + store: {}, + async ensureBootstrap() {}, + async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; } + }; + const runtimeStore = createDurableFactsRuntimeStore({ + sessions: [{ session, events: [], status: "completed", finalText: "OK", runId: "run_workbench_trace_metadata_gap", commandId: "cmd_workbench_trace_metadata_gap", lastProjectedSeq: 2, omitSessions: true }] + }); + const server = createCloudApiServer({ accessController, traceStore, runtimeStore, codeAgentChatResults: createCodeAgentChatResultStore() }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`); + assert.equal(turn.status, 200); + assert.equal(turn.body.turn.status, "completed"); + + const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=10`); + assert.equal(trace.status, 404); + assert.equal(trace.body.error.code, "workbench_trace_metadata_missing"); + assert.equal(trace.body.error.message, "Workbench trace metadata is missing from the trace events read model."); + assert.equal(trace.body.error.projectionStatus, "blocked"); + assert.equal(trace.body.error.projectionHealth, "degraded"); + assert.equal(trace.body.error.lastProjectedSeq, 2); + assert.equal(trace.body.error.sourceRunId, "run_workbench_trace_metadata_gap"); + assert.equal(trace.body.error.sourceCommandId, "cmd_workbench_trace_metadata_gap"); + assert.equal(trace.body.error.blocker.code, "workbench_trace_metadata_missing"); + assert.notEqual(trace.body.error.message, "Workbench trace is not visible to the current actor."); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + +test("workbench trace events reports missing event page when session projection is visible", async () => { + const traceStore = createCodeAgentTraceStore(); + const traceId = "trc_workbench_trace_events_gap"; + const session = { + id: "ses_workbench_trace_events_gap", + projectId: "prj_hwpod_workbench", + agentId: "hwlab-code-agent", + status: "completed", + ownerUserId: ACTOR.id, + conversationId: "cnv_workbench_trace_events_gap", + threadId: "thread-workbench-trace-events-gap", + lastTraceId: traceId, + updatedAt: "2026-06-20T13:05:00.000Z", + session: { sessionStatus: "completed", lastTraceId: traceId, messages: [{ role: "user", text: "events gap", traceId }, { role: "agent", text: "OK", traceId }] } + }; + const accessController = { + store: {}, + async ensureBootstrap() {}, + async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; } + }; + const runtimeStore = createDurableFactsRuntimeStore({ + sessions: [{ session, events: [], status: "completed", finalText: "OK", runId: "run_workbench_trace_events_gap", commandId: "cmd_workbench_trace_events_gap", lastProjectedSeq: 2 }] + }); + const server = createCloudApiServer({ accessController, traceStore, runtimeStore, codeAgentChatResults: createCodeAgentChatResultStore() }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`); + assert.equal(turn.status, 200); + assert.equal(turn.body.turn.status, "completed"); + + const trace = await getJson(port, `/v1/workbench/traces/${encodeURIComponent(traceId)}/events?limit=10`); + assert.equal(trace.status, 404); + assert.equal(trace.body.error.code, "workbench_trace_events_missing"); + assert.equal(trace.body.error.message, "Workbench trace events are missing from the durable read model."); + assert.equal(trace.body.error.sessionId, session.id); + assert.equal(trace.body.error.projectionStatus, "blocked"); + assert.equal(trace.body.error.projectionHealth, "degraded"); + assert.equal(trace.body.error.lastProjectedSeq, 2); + assert.equal(trace.body.error.sourceRunId, "run_workbench_trace_events_gap"); + assert.equal(trace.body.error.sourceCommandId, "cmd_workbench_trace_events_gap"); + assert.equal(trace.body.error.blocker.code, "workbench_trace_events_missing"); + assert.notEqual(trace.body.error.message, "Workbench trace is not visible to the current actor."); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + test("workbench read model projects current turn running state from trace projection", async () => { const traceStore = createCodeAgentTraceStore(); const traceId = "trc_workbench_active_state"; @@ -1430,7 +1527,7 @@ function createDurableFactsRuntimeStore({ sessions = [], queryError = null, quer }; } -function buildDurableFactsForSession({ session, events = [], status = null, finalText = null, runId = null, commandId = null, projectionStatus = null, projectionHealth = "healthy", lastProjectedSeq = null } = {}) { +function buildDurableFactsForSession({ session, events = [], status = null, finalText = null, runId = null, commandId = null, projectionStatus = null, projectionHealth = "healthy", lastProjectedSeq = null, omitSessions = false } = {}) { const traceId = session.lastTraceId ?? session.session?.lastTraceId; const projectedStatus = normalizeTestStatus(status ?? session.status); const terminal = testTerminalStatuses.has(projectedStatus); @@ -1441,7 +1538,7 @@ function buildDurableFactsForSession({ session, events = [], status = null, fina const projectedSeq = Number.isFinite(explicitLastProjectedSeq) ? explicitLastProjectedSeq : Math.max(normalizedEvents.length, normalizedMessages.length, 0); const checkpointStatus = projectionStatus ?? (terminal ? "caught_up" : "projecting"); return { - sessions: [{ + sessions: omitSessions ? [] : [{ sessionId: session.id, ownerUserId: session.ownerUserId, ownerRole: session.ownerRole ?? null, diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index b0de1a86..bc5d5c07 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -1034,14 +1034,29 @@ async function handleWorkbenchTraceEventPage(response, url, options, actor, rawT const pageOptions = tracePageOptions(url); const metadata = await readModel.queryFacts({ traceId, families: WORKBENCH_TRACE_EVENT_METADATA_FAMILIES, limit: 1 }); if (metadata.error) return sendJson(response, 503, workbenchProjectionStoreError(metadata.error)); - const session = visibleFactSessions(metadata.facts, actor).find((item) => item.lastTraceId === traceId) ?? visibleFactSessions(metadata.facts, actor)[0] ?? null; + const metadataSessions = visibleFactSessions(metadata.facts, actor); + const session = metadataSessions.find((item) => item.lastTraceId === traceId) ?? metadataSessions[0] ?? null; if (!session) { + const context = await readModel.queryFacts({ traceId, families: ["sessions", "turns", "checkpoints"], limit: MAX_PAGE_LIMIT }); + if (context.error) return sendJson(response, 503, workbenchProjectionStoreError(context.error)); + const contextSessions = visibleFactSessions(context.facts, actor); + const contextSession = contextSessions.find((item) => item.lastTraceId === traceId) ?? contextSessions[0] ?? null; + const contextTurn = factTurnForTrace(context.facts, traceId, traceId); + if (contextSession || contextTurn) { + const projection = factProjectionForTrace(context.facts, traceId); + const blocker = traceEventsReadModelBlocker("workbench_trace_metadata_missing", "Workbench trace metadata is missing from the trace events read model.", { traceId, projection, session: contextSession, turn: contextTurn, route: url.pathname }); + return sendJson(response, 404, workbenchTraceEventsReadModelError(blocker, { traceId, projection, session: contextSession, turn: contextTurn })); + } return sendJson(response, 404, workbenchError("workbench_trace_not_found", "Workbench trace is not visible to the current actor.", { traceId })); } const projection = factProjectionForTrace(metadata.facts, traceId); const pageResult = await readModel.queryFacts({ traceId, families: WORKBENCH_TRACE_EVENT_PAGE_FAMILIES, afterProjectedSeq: pageOptions.afterProjectedSeq, limit: pageOptions.limit + 1 }); if (pageResult.error) return sendJson(response, 503, workbenchProjectionStoreError(pageResult.error)); const page = traceEventPageFromFacts(pageResult.facts.traceEvents, pageOptions, { total: projection.lastProjectedSeq, traceStatus: session.status }); + if (traceEventPageMissing(page, projection, pageOptions)) { + const blocker = traceEventsReadModelBlocker("workbench_trace_events_missing", "Workbench trace events are missing from the durable read model.", { traceId, projection, session, route: url.pathname }); + return sendJson(response, 404, workbenchTraceEventsReadModelError(blocker, { traceId, projection, session })); + } const responseProjection = page.blocker ? blockedTraceEventProjection(projection, page.blocker) : projection; sendJson(response, 200, { ok: true, @@ -1153,6 +1168,65 @@ function blockedTraceEventProjection(projection = {}, blocker) { }; } +function traceEventPageMissing(page = {}, projection = {}, options = {}) { + if (page.blocker) return false; + const expectedSeq = Number(projection?.lastProjectedSeq); + if (!Number.isFinite(expectedSeq) || expectedSeq <= 0) return false; + return factArray(page.events).length === 0 && expectedSeq > Number(options.afterProjectedSeq ?? 0); +} + +function traceEventsReadModelBlocker(code, message, { traceId, projection = {}, session = null, turn = null, route = null } = {}) { + return { + code, + message, + userMessage: message, + layer: "workbench-read-model", + category: "trace-events", + retryable: true, + route, + traceId, + sessionId: factSessionId(session) ?? turn?.sessionId ?? null, + turnId: factTurnId(turn, traceId), + projectionStatus: projection.projectionStatus ?? null, + projectionHealth: "degraded", + lastProjectedSeq: projection.lastProjectedSeq ?? null, + sourceRunId: projection.sourceRunId ?? null, + sourceCommandId: projection.sourceCommandId ?? null, + valuesPrinted: false + }; +} + +function workbenchTraceEventsReadModelError(blocker, { traceId, projection = {}, session = null, turn = null } = {}) { + const responseProjection = blockedTraceEventProjection(projection, blocker); + return workbenchError(blocker.code, blocker.message, { + traceId, + sessionId: blocker.sessionId ?? factSessionId(session) ?? turn?.sessionId ?? null, + turnId: blocker.turnId ?? factTurnId(turn, traceId), + route: blocker.route ?? null, + layer: blocker.layer, + category: blocker.category, + retryable: blocker.retryable, + projection: responseProjection, + projectionStatus: responseProjection.projectionStatus, + projectionHealth: responseProjection.projectionHealth, + lastProjectedSeq: responseProjection.lastProjectedSeq, + sourceRunId: responseProjection.sourceRunId, + sourceCommandId: responseProjection.sourceCommandId, + blocker, + diagnostic: { + contractVersion: "hwlab-error-diagnostic-v1", + route: blocker.route ?? null, + layer: blocker.layer, + category: blocker.category, + code: blocker.code, + httpStatus: 404, + source: "server", + retryable: blocker.retryable, + valuesPrinted: false + } + }); +} + async function visibleSessionById(store, sessionId, actor) { const safeId = safeSessionId(sessionId); if (!safeId) return null; diff --git a/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue b/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue index 03ed0091..11acb884 100644 --- a/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue +++ b/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue @@ -1,5 +1,5 @@ diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 59c6481f..b4283365 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -289,12 +289,13 @@ } .api-error-diagnostic { - display: grid; - width: 100%; - gap: 10px; + display: inline-grid; + max-width: min(720px, 100%); + width: fit-content; + gap: 8px; border-left: 3px solid #b91c1c; background: #f8fafc; - padding: 10px 12px; + padding: 8px 10px; text-align: left; } @@ -303,11 +304,20 @@ margin: 4px auto 0; } -.api-error-diagnostic header { - display: flex; - align-items: flex-start; +.api-error-diagnostic-summary { + display: inline-flex; + min-width: 0; + align-items: center; justify-content: space-between; - gap: 12px; + gap: 8px; +} + +.api-error-diagnostic-summary-text { + display: inline-flex; + min-width: 0; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; } .api-error-diagnostic strong { @@ -317,10 +327,41 @@ } .api-error-diagnostic p { - margin: 2px 0 0; + min-width: 0; + margin: 0; + overflow-wrap: anywhere; color: #334155; font-size: 13px; - line-height: 1.5; + line-height: 1.35; +} + +.api-error-diagnostic-toggle { + display: inline-flex; + width: 22px; + height: 22px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid #f59e0b; + border-radius: 999px; + background: #fff7ed; + color: #92400e; + cursor: pointer; + padding: 0; + font-size: 12px; + font-weight: 850; + line-height: 1; +} + +.api-error-diagnostic-toggle[aria-expanded="true"] { + background: #fffbeb; + color: #78350f; +} + +.api-error-diagnostic-details { + display: grid; + gap: 8px; + justify-items: start; } .api-error-diagnostic-grid { diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts index cecce3ee..eb558759 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/admin-hwpod-performance.spec.ts @@ -1,4 +1,4 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { expect, fakeServerState, test } from "../fixtures/test"; @@ -34,7 +34,7 @@ test("admin HWPOD groups renders node-ops as structured UI before raw JSON is re test("performance dashboard renders Chinese charts and keeps drill-down reachable", async ({ page }) => { await page.goto("/performance"); - await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible(); await expect(page.locator(".performance-contract-item")).toHaveCount(5); await expect(page.locator(".performance-contract-item").filter({ hasText: "观测窗口" })).toContainText("最近 15 分钟"); await expect(page.locator(".performance-contract-item").filter({ hasText: "生成时间" })).toContainText("fake-server"); @@ -83,7 +83,7 @@ test("performance dashboard renders Chinese charts and keeps drill-down reachabl await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/performance"); - await expect(page.getByText("性能明细")).toBeVisible(); + await expect(page.getByRole("heading", { name: "性能明细" })).toBeVisible(); await expect(page.locator("svg.performance-line-chart")).toHaveCount(3); await page.screenshot({ path: ".state/workbench-e2e/performance-dashboard-mobile.png", fullPage: true }); @@ -107,7 +107,7 @@ test.describe("performance dashboard empty collection", () => { test("renders the empty summary contract without invented rows", async ({ page }) => { await page.goto("/performance"); - await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible(); await expect(page.locator(".performance-contract-item")).toHaveCount(5); await expect(page.locator(".performance-contract-item").filter({ hasText: "样本覆盖" })).toContainText("0 样本"); await expect(page.locator(".performance-collection-item")).toHaveCount(4); @@ -126,7 +126,7 @@ test.describe("performance dashboard stale current window", () => { test("keeps retained samples separate from the empty current window", async ({ page }) => { await page.goto("/performance"); - await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible(); await expect(page.locator(".performance-contract-item").filter({ hasText: "样本覆盖" })).toContainText("0 样本"); await expect(page.locator(".performance-collection-item").filter({ hasText: "最近样本" })).toContainText("当前窗口暂无样本"); await expect(page.locator(".performance-collection-item").filter({ hasText: "当前窗口样本" })).toContainText("0 样本"); @@ -147,8 +147,9 @@ test.describe("performance dashboard summary error", () => { await expect(page.getByText("性能监控加载失败")).toBeVisible(); await expect(page.getByText("暂时无法连接上游。")).toBeVisible(); const diagnostic = page.locator(".api-error-diagnostic"); - await expect(diagnostic).toContainText("trace_id"); - await expect(diagnostic).toContainText("11111111111111111111111111111111"); + await expectDiagnosticSummary(diagnostic, "11111111111111111111111111111111"); + await expect(diagnostic).not.toContainText("requestId"); + await expandDiagnostic(diagnostic); await expect(diagnostic).toContainText("requestId"); await expect(diagnostic).toContainText("req_e2e_api_error_diagnostic"); await expect(diagnostic).toContainText("upstream_unavailable"); @@ -168,11 +169,12 @@ for (const status of performanceErrorStatuses) { test(`surfaces HTTP ${status} trace diagnostics without stale dashboard data`, async ({ page }) => { await page.goto("/performance"); - await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible(); await expect(page.getByText("性能监控加载失败")).toBeVisible(); const diagnostic = page.locator(".api-error-diagnostic"); - await expect(diagnostic).toContainText("trace_id"); - await expect(diagnostic).toContainText(statusTraceId(status)); + await expectDiagnosticSummary(diagnostic, statusTraceId(status)); + await expect(diagnostic).not.toContainText("requestId"); + await expandDiagnostic(diagnostic); await expect(diagnostic).toContainText("requestId"); await expect(diagnostic).toContainText(`req_e2e_api_status_${status}`); await expect(diagnostic).toContainText(`e2e_http_${status}`); @@ -192,10 +194,13 @@ test.describe("performance dashboard browser network diagnostic", () => { test("surfaces browser generated trace diagnostics when the request has no response", async ({ page }) => { await page.goto("/performance"); - await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "性能监控", exact: true })).toBeVisible(); await expect(page.getByText("性能监控加载失败")).toBeVisible(); const diagnostic = page.locator(".api-error-diagnostic"); await expect(diagnostic).toContainText("trace_id"); + await expect(diagnostic.getByRole("button", { name: "诊断详情" })).toHaveAttribute("aria-expanded", "false"); + await expect(diagnostic).not.toContainText("requestId"); + await expandDiagnostic(diagnostic); await expect(diagnostic).toContainText("requestId"); await expect(diagnostic).toContainText("browser_network_error"); await expect(diagnostic).toContainText("web / network"); @@ -210,3 +215,16 @@ test.describe("performance dashboard browser network diagnostic", () => { function statusTraceId(status: number): string { return String(status).repeat(11).slice(0, 32); } + +async function expectDiagnosticSummary(diagnostic: Locator, traceId: string): Promise { + await expect(diagnostic).toContainText(`trace_id=${traceId}`); + const toggle = diagnostic.getByRole("button", { name: "诊断详情" }); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(toggle).toBeVisible(); +} + +async function expandDiagnostic(diagnostic: Locator): Promise { + const toggle = diagnostic.getByRole("button", { name: "诊断详情" }); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); +} diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts index c19503ed..31cf9122 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts @@ -1,6 +1,7 @@ // SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response. // Responsibility: Workbench diagnostic visibility regression, including separation from sealed final response. +import type { Locator } from "@playwright/test"; import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test"; import { selectors, sessionTab } from "../fixtures/selectors"; @@ -13,11 +14,15 @@ test.describe("projection diagnostics visibility", () => { await expect(page.locator(sessionTab("ses_projection_degraded"))).toHaveAttribute("data-projection-health", "degraded"); const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last(); await expect(card).toContainText("状态更新超时"); - await expect(card.locator(".projection-diagnostic")).toContainText("agentrun_result_timeout"); - await expect(card.locator(".projection-diagnostic")).toContainText("trace_id"); - await expect(card.locator(".projection-diagnostic")).toContainText("trc_projection_degraded"); - await expect(card.locator(".projection-diagnostic")).toContainText("valuesPrinted"); - await expect(card.locator(".projection-diagnostic")).toContainText("false"); + const diagnostic = card.locator(".projection-diagnostic"); + await expectCompactDiagnostic(diagnostic, card, "trc_projection_degraded"); + await expect(diagnostic).not.toContainText("valuesPrinted"); + await expandDiagnostic(diagnostic); + await expect(diagnostic).toContainText("agentrun_result_timeout"); + await expect(diagnostic).toContainText("trace_id"); + await expect(diagnostic).toContainText("trc_projection_degraded"); + await expect(diagnostic).toContainText("valuesPrinted"); + await expect(diagnostic).toContainText("false"); await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded"); await expect(card.locator(".trace-empty")).toContainText("状态更新超时"); await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel"); @@ -32,9 +37,13 @@ test.describe("SSE projection errors", () => { await gotoWorkbench(page, "/workbench/sessions/ses_projection_sse_error"); const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last(); await expect(card).toContainText("SSE 投影事件异常"); - await expect(card.locator(".projection-diagnostic")).toContainText("workbench_sse_projection_error"); - await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_workbench_sse_projection_error"); - await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/events"); + const diagnostic = card.locator(".projection-diagnostic"); + await expectCompactDiagnostic(diagnostic, card, "trc_projection_sse_error"); + await expect(diagnostic).not.toContainText("req_e2e_workbench_sse_projection_error"); + await expandDiagnostic(diagnostic); + await expect(diagnostic).toContainText("workbench_sse_projection_error"); + await expect(diagnostic).toContainText("req_e2e_workbench_sse_projection_error"); + await expect(diagnostic).toContainText("/v1/workbench/events"); await expect(page.locator(sessionTab("ses_projection_sse_error"))).toHaveAttribute("data-projection-health", "degraded"); await saveScreenshot(page, testInfo, "projection-sse-error-visible"); }); @@ -47,10 +56,15 @@ test.describe("REST gap hydration errors", () => { await gotoWorkbench(page, "/workbench/sessions/ses_trace_hydration_timeout"); const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last(); await expect(card).toContainText("Trace 更新超时"); - await expect(card.locator(".projection-diagnostic")).toContainText("trace_hydration_timeout"); - await expect(card.locator(".projection-diagnostic")).toContainText("33333333333333333333333333333333"); - await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_trace_hydration_timeout"); - await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events"); + const diagnostic = card.locator(".projection-diagnostic"); + await expectCompactDiagnostic(diagnostic, card, "33333333333333333333333333333333"); + await expect(diagnostic).not.toContainText("req_e2e_trace_hydration_timeout"); + await expect(diagnostic).not.toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events"); + await expandDiagnostic(diagnostic); + await expect(diagnostic).toContainText("trace_hydration_timeout"); + await expect(diagnostic).toContainText("33333333333333333333333333333333"); + await expect(diagnostic).toContainText("req_e2e_trace_hydration_timeout"); + await expect(diagnostic).toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events"); await expect(card).toHaveAttribute("data-status", "running"); await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded"); await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel"); @@ -64,9 +78,13 @@ test.describe("sealed final response diagnostics separation", () => { test("keeps completed final response visible while showing diagnostics separately", async ({ page }, testInfo) => { await gotoWorkbench(page, "/workbench/sessions/ses_sealed_final_diag"); const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last(); - await expect(card.locator(".projection-diagnostic")).toContainText("Trace 更新超时"); - await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_sealed_final_trace_timeout"); - await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_sealed_final_diag/events"); + const diagnostic = card.locator(".projection-diagnostic"); + await expectCompactDiagnostic(diagnostic, card, "55555555555555555555555555555555"); + await expect(diagnostic).not.toContainText("req_e2e_sealed_final_trace_timeout"); + await expandDiagnostic(diagnostic); + await expect(diagnostic).toContainText("Trace 更新超时"); + await expect(diagnostic).toContainText("req_e2e_sealed_final_trace_timeout"); + await expect(diagnostic).toContainText("/v1/workbench/traces/trc_sealed_final_diag/events"); const finalResponse = card.locator(".message-text").filter({ hasText: "已完成的 sealed final response 不应被诊断覆盖。" }); await expect(finalResponse).toBeVisible(); await expect(finalResponse).not.toContainText("Trace 更新超时"); @@ -74,6 +92,23 @@ test.describe("sealed final response diagnostics separation", () => { }); }); +async function expectCompactDiagnostic(diagnostic: Locator, container: Locator, traceId: string): Promise { + await expect(diagnostic).toContainText(`trace_id=${traceId}`); + const toggle = diagnostic.getByRole("button", { name: "诊断详情" }); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(toggle).toBeVisible(); + const diagnosticBox = await diagnostic.boundingBox(); + const containerBox = await container.boundingBox(); + expect(diagnosticBox?.width ?? 0).toBeGreaterThan(0); + if (containerBox && diagnosticBox) expect(diagnosticBox.width).toBeLessThan(containerBox.width - 12); +} + +async function expandDiagnostic(diagnostic: Locator): Promise { + const toggle = diagnostic.getByRole("button", { name: "诊断详情" }); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); +} + test.describe("trace detail final response separation", () => { test.use({ scenarioId: "trace-final-response-not-message-authority" });