From 7ed2968b648c6a042953d061d4ed6997c87cc4fe Mon Sep 17 00:00:00 2001 From: lyon Date: Wed, 17 Jun 2026 19:06:23 +0800 Subject: [PATCH] fix: stabilize workbench session restore --- internal/cloud/access-control.test.ts | 3 +- internal/cloud/access-control.ts | 16 +++- .../scripts/workbench-e2e-server.ts | 77 +++++++++++++++++-- web/hwlab-cloud-web/src/stores/workbench.ts | 24 +++--- web/hwlab-cloud-web/src/styles/workbench.css | 37 ++++++++- .../tests/workbench-e2e/fixtures/selectors.ts | 1 + .../specs/mobile-session-create.spec.ts | 15 ++++ .../specs/new-session-thread.spec.ts | 23 ++++++ .../specs/stale-workspace-trace.spec.ts | 18 +++++ 9 files changed, 195 insertions(+), 19 deletions(-) create mode 100644 web/hwlab-cloud-web/tests/workbench-e2e/specs/mobile-session-create.spec.ts create mode 100644 web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts create mode 100644 web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index c84aa42b..7bea5c5a 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -717,7 +717,8 @@ test("workbench workspace read preserves a newer selected conversation without t assert.equal(restored.body.workspace.selectedConversation.sessionId, "ses_issue808_new"); assert.equal(restored.body.workspace.workspace.selectedConversationId, "cnv_issue808_new"); assert.equal(restored.body.workspace.workspace.selectedAgentSessionId, "ses_issue808_new"); - assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue808_old_done"); + assert.equal(restored.body.workspace.workspace.lastTraceId, undefined); + assert.notEqual(restored.body.workspace.workspace.sessionStatus, "running"); assert.equal(agentRunCalls.length, 0); const selectedOld = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 90cb0c23..23140d60 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -2101,7 +2101,7 @@ function publicWorkbenchWorkspace(workspace, { conversation = null } = {}) { activeTraceId: workspace.activeTraceId, providerProfile: workspace.providerProfile, selectedConversation: conversation, - workspace: redactedWorkspaceJson(workspace.workspace), + workspace: redactedWorkspaceJson(workspaceJsonForSelectedConversation(workspace, conversation)), updatedBySessionId: workspace.updatedBySessionId, updatedByClient: workspace.updatedByClient, createdAt: workspace.createdAt, @@ -2110,6 +2110,20 @@ function publicWorkbenchWorkspace(workspace, { conversation = null } = {}) { secretMaterialStored: false }; } +function workspaceJsonForSelectedConversation(workspace, conversation = null) { + const snapshot = normalizeObject(workspace?.workspace); + const selectedConversationId = textOr(workspace?.selectedConversationId, ""); + if (!conversation || textOr(conversation.conversationId, "") !== selectedConversationId) return snapshot; + return { + ...snapshot, + selectedConversationId, + selectedAgentSessionId: textOr(workspace.selectedAgentSessionId ?? conversation.sessionId, ""), + activeTraceId: textOr(workspace.activeTraceId, ""), + providerProfile: textOr(workspace.providerProfile, snapshot.providerProfile), + sessionStatus: textOr(conversation.status, ""), + lastTraceId: textOr(conversation.lastTraceId, "") + }; +} function redactedWorkspaceJson(value = {}) { const workspace = normalizeObject(value); return pruneEmpty({ diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 308b5516..3ccc4238 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -26,9 +26,11 @@ interface ScenarioState { conversations: ConversationRecord[]; traces: Record; selectRequests: JsonRecord[]; + chatRequests: JsonRecord[]; listOmitSelected: boolean; conversationDelayMs: number; terminalScript: boolean; + staleNestedTraceId: string | null; } const cwd = process.cwd(); @@ -85,10 +87,17 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const body = await readJson(request); state.selectRequests.push(redactRequestBody(body)); const conversationId = String(body.conversationId ?? ""); + if (conversationId && !conversationById(conversationId) && body.create === true) state.conversations.unshift(createConversationFromSelect(body, conversationId)); if (conversationId && conversationById(conversationId)) state.selectedConversationId = conversationId; return json(response, 200, { workspace: workspacePayload() }); } + if (path === "/v1/agent/chat" && method === "POST") { + const body = await readJson(request); + state.chatRequests.push(redactRequestBody(body)); + return json(response, 200, acceptChatTurn(body)); + } + if (path === "/v1/agent/conversations" && method === "GET") { await delay(state.conversationDelayMs); const include = url.searchParams.get("includeConversationId"); @@ -106,10 +115,18 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (sessionMatch && method === "GET") return json(response, 200, { session: sessionPayload(decodeURIComponent(sessionMatch[1] ?? "")) }); const turnMatch = path.match(/^\/v1\/agent\/turns\/([^/]+)$/u); - if (turnMatch && method === "GET") return json(response, 200, turnPayload(decodeURIComponent(turnMatch[1] ?? ""))); + if (turnMatch && method === "GET") { + const traceId = decodeURIComponent(turnMatch[1] ?? ""); + if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); + return json(response, 200, turnPayload(traceId)); + } const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u); - if (traceMatch && method === "GET") return json(response, 200, tracePayload(decodeURIComponent(traceMatch[1] ?? ""), url)); + if (traceMatch && method === "GET") { + const traceId = decodeURIComponent(traceMatch[1] ?? ""); + if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); + return json(response, 200, tracePayload(traceId, url)); + } if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] }); if (path === "/health/live" || path === "/health" || path === "/v1") return json(response, 200, { status: "ok", serviceId: "workbench-e2e", codeAgent: { ready: true, status: "ready" } }); @@ -124,7 +141,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) function createScenarioState(scenarioId: string): ScenarioState { const base = structuredClone(capture.scenario); const id = scenarioId || "baseline"; - const selectedConversationId = id === "deep-link" ? "cnv_failed" : base.selectedConversationId; + const selectedConversationId = id === "deep-link" || id === "stale-nested-trace" ? "cnv_failed" : base.selectedConversationId; return { scenarioId: id, projectId: base.projectId, @@ -134,14 +151,17 @@ function createScenarioState(scenarioId: string): ScenarioState { conversations: base.conversations, traces: base.traces, selectRequests: [], + chatRequests: [], listOmitSelected: id === "selected-missing-from-list", conversationDelayMs: id === "loading" ? 2_500 : 0, - terminalScript: id === "event-replay" || id === "running-to-terminal" + terminalScript: id === "event-replay" || id === "running-to-terminal", + staleNestedTraceId: id === "stale-nested-trace" ? "trc_stale_502" : null }; } function workspacePayload(): JsonRecord { const selected = conversationById(state.selectedConversationId); + const nestedLastTraceId = state.staleNestedTraceId ?? selected?.lastTraceId ?? null; return { workspaceId: state.workspaceId, projectId: state.projectId, @@ -151,6 +171,7 @@ function workspacePayload(): JsonRecord { selectedAgentSessionId: selected?.sessionId ?? null, activeTraceId: selected?.status === "running" ? selected.lastTraceId ?? null : null, providerProfile: state.providerProfile, + selectedConversation: selected ?? null, workspace: { projectId: state.projectId, selectedConversationId: state.selectedConversationId, @@ -158,12 +179,56 @@ function workspacePayload(): JsonRecord { threadId: selected?.threadId ?? null, sessionStatus: selected?.status ?? null, providerProfile: state.providerProfile, - lastTraceId: selected?.lastTraceId ?? null, + lastTraceId: nestedLastTraceId, updatedAt: new Date().toISOString() } }; } +function createConversationFromSelect(body: JsonRecord, conversationId: string): ConversationRecord { + const token = conversationId.slice(4); + const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_${token}`; + const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null; + const now = new Date().toISOString(); + return { + conversationId, + projectId: state.projectId, + sessionId, + threadId, + status: "active", + startedAt: now, + updatedAt: now, + messageCount: 0, + firstUserMessagePreview: null, + session: { sessionId, threadId, status: "active" }, + messages: [] + }; +} + +function acceptChatTurn(body: JsonRecord): JsonRecord { + const conversationId = typeof body.conversationId === "string" ? body.conversationId : state.selectedConversationId; + const sessionId = typeof body.sessionId === "string" ? body.sessionId : conversationById(conversationId)?.sessionId ?? `ses_${conversationId.slice(4)}`; + const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null; + const traceId = typeof body.traceId === "string" && body.traceId.trim() ? body.traceId : `trc_${Date.now().toString(16)}`; + const message = typeof body.message === "string" ? body.message : ""; + const conversation = conversationById(conversationId); + const now = new Date().toISOString(); + if (conversation) { + conversation.status = "running"; + conversation.lastTraceId = traceId; + conversation.updatedAt = now; + conversation.firstUserMessagePreview = conversation.firstUserMessagePreview ?? message; + conversation.messageCount = (conversation.messages?.length ?? 0) + 2; + conversation.messages = [ + ...(conversation.messages ?? []), + { id: `msg_${traceId}_user`, role: "user", title: "用户", text: message, status: "sent", createdAt: now, conversationId, sessionId, threadId, traceId }, + { id: `msg_${traceId}_agent`, role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, conversationId, sessionId, threadId, traceId, runnerTrace: { traceId, status: "running", sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } } + ]; + } + state.traces[traceId] = { traceId, status: "running", conversationId, sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false }; + return { status: "running", accepted: true, running: true, terminal: false, traceId, conversationId, sessionId, threadId, turnId: traceId }; +} + function conversationSummary(conversation: ConversationRecord): ConversationRecord { const { messages: _messages, ...rest } = conversation; return rest as ConversationRecord; @@ -245,7 +310,7 @@ function clearActiveTraceForSelection(): void { } function stateSummary(): JsonRecord { - return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, status: item.status, lastTraceId: item.lastTraceId })) }; + return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) }; } function authPayload(): JsonRecord { diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 662914ed..00a26ea0 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -197,7 +197,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { rememberWorkspaceSnapshot(tabProjectId, workspace.value); void refreshSessionStatusById(sessionIdFromConversation(conversation)); loading.value = true; - const persistPromise = api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" }); + const persistPromise = api.workbench.selectConversation(current.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue" }); const detailResponse = await api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId }); const response = await persistPromise; loading.value = false; @@ -315,7 +315,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { create: true, conversationId: conversation.conversationId, sessionId: conversation.sessionId, - threadId: conversation.threadId, + threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue-fast-create" }); if (!response.ok || !response.data?.workspace) { @@ -478,6 +478,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { setActiveConversationSelection(conversationId); const sessionId = composer.value.sessionId; const threadId = composer.value.threadId; + const providerThreadId = providerThreadIdForRequest(threadId); const user = makeMessage("user", value, "sent", { traceId: steerTraceId ?? traceId, conversationId, sessionId, threadId, title: steerMode ? "用户引导" : "用户" }); const pending = makeMessage("agent", "", "running", { traceId, conversationId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running" }); messages.value.push(user, pending); @@ -495,7 +496,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { currentRequest.value = null; return; } - const payload = { projectId: activeProjectId.value, message: value, prompt: value, conversationId, sessionId, threadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, workspaceId: workspace.value?.workspaceId, workspaceRevision: workspace.value?.revision, targetTraceId: composer.value.targetTraceId, steerTraceId }; + const payload = { projectId: activeProjectId.value, message: value, prompt: value, conversationId, sessionId, threadId: providerThreadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, workspaceId: workspace.value?.workspaceId, workspaceRevision: workspace.value?.revision, targetTraceId: composer.value.targetTraceId, steerTraceId }; const route = steerMode ? api.agent.steerAgentMessage : api.agent.sendAgentMessage; const response = await route(payload, codeAgentTimeoutMs.value, () => activityRef.value); if (!response.ok || !response.data) { @@ -1028,13 +1029,13 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!response) { const workspaceId = workspace.value?.workspaceId; if (!workspaceId) return; - response = await api.workbench.selectConversation(workspaceId, { projectId: selectedProjectId, conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue" }); + response = await api.workbench.selectConversation(workspaceId, { projectId: selectedProjectId, conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue" }); } if (!response.ok && response.status === 404) { const fresh = await api.workbench.workspace(selectedProjectId); const freshWorkspace = fresh.data?.workspace; if (fresh.ok && freshWorkspace?.workspaceId && activeConversationId.value === conversationId) { - response = await api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: selectedProjectId, conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue-retry" }); + response = await api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: selectedProjectId, conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue-retry" }); } } if (!response.ok || !response.data?.workspace || activeConversationId.value !== conversationId) return; @@ -1057,7 +1058,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { rememberWorkspaceSnapshot(tabProjectId, workspace.value); rememberConversationDetail(conversation); const [retried, detailResponse] = await Promise.all([ - api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: conversation.threadId, updatedByClient: "cloud-web-vue-retry" }), + api.workbench.selectConversation(freshWorkspace.workspaceId, { projectId: tabProjectId, conversationId: conversation.conversationId, sessionId: conversation.sessionId, threadId: providerThreadIdForRequest(conversation.threadId), updatedByClient: "cloud-web-vue-retry" }), api.workbench.conversation(conversation.conversationId, { projectId: tabProjectId }) ]); if (!isCurrentWorkspaceSelection(requestEpoch, conversation.conversationId)) { @@ -1109,24 +1110,29 @@ function sessionIdFromConversation(conversation: ConversationRecord | null | und return firstNonEmptyString(conversation?.sessionId, conversation?.session?.sessionId) ?? null; } +function providerThreadIdForRequest(threadId: string | null | undefined): string | null { + const value = firstNonEmptyString(threadId) ?? null; + if (!value) return null; + return /^thr_[A-Za-z0-9]{12,32}$/u.test(value) ? null : value; +} + function makeOptimisticConversation(projectId: string, now = new Date()): ConversationRecord { const token = nextProtocolId("cnv").slice(4); const title = `新会话 ${shortSessionTimestamp(now)}`; const createdAt = now.toISOString(); const sessionId = `ses_${token}`; - const threadId = `thr_${token}`; return { conversationId: `cnv_${token}`, projectId, sessionId, - threadId, + threadId: null, status: "active", title, name: title, startedAt: createdAt, updatedAt: createdAt, messageCount: 0, - session: { sessionId, threadId, status: "active" }, + session: { sessionId, status: "active" }, messages: [] }; } diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 33bf63a7..2a3f87b8 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -2452,14 +2452,47 @@ .workbench-grid { grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); } - .session-rail, .hwpod-panel { display: none; } + .session-rail { + display: grid; + width: 100% !important; + max-height: 168px; + grid-template-rows: auto minmax(0, 1fr); + align-content: stretch; + overflow: hidden; + } + + .session-rail .panel-header.compact { + min-height: 34px; + } + + .session-rail .panel-header.compact .page-eyebrow, + .session-rail .session-model-channel, + .session-rail .session-active-metadata, + .session-rail .session-rail-actions, + .session-rail .session-resize-handle { + display: none; + } + + .session-rail .panel-header.compact h2 { + font-size: 14px; + } + + #session-create { + min-width: 56px; + min-height: 32px; + } + + .session-list { + max-height: 104px; + } + .command-composer { gap: 8px; padding: 10px; diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts index e5c628ca..56088005 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts @@ -3,6 +3,7 @@ export const selectors = { workspace: "#workspace", + sessionCreate: "#session-create", sessionTabs: "#session-tabs", sessionStatus: "#session-status", commandInput: "#command-input", diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/mobile-session-create.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/mobile-session-create.spec.ts new file mode 100644 index 00000000..d2808c65 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/mobile-session-create.spec.ts @@ -0,0 +1,15 @@ +import { expect, gotoWorkbench, test } from "../fixtures/test"; +import { selectors } from "../fixtures/selectors"; + +test.use({ viewport: { width: 390, height: 844 } }); + +test("mobile Workbench exposes a visible create session entry", async ({ page }) => { + await gotoWorkbench(page); + const create = page.locator(selectors.sessionCreate); + await expect(create).toBeVisible(); + const box = await create.boundingBox(); + expect(box?.width ?? 0).toBeGreaterThan(32); + expect(box?.height ?? 0).toBeGreaterThan(24); + await create.click(); + await expect(page.locator('.session-tab[data-active="true"]')).toHaveAttribute("data-conversation-id", /^cnv_/u); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts new file mode 100644 index 00000000..47da11cb --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/new-session-thread.spec.ts @@ -0,0 +1,23 @@ +import { expect, fakeServerState, gotoWorkbench, test } from "../fixtures/test"; +import { selectors } from "../fixtures/selectors"; + +test("new optimistic session first turn does not submit local thr_* as upstream thread", async ({ page }) => { + await gotoWorkbench(page); + await page.locator(selectors.sessionCreate).click(); + const activeTab = page.locator('.session-tab[data-active="true"]'); + await expect(activeTab).toHaveAttribute("data-conversation-id", /^cnv_/u); + const conversationId = await activeTab.getAttribute("data-conversation-id"); + expect(conversationId).toBeTruthy(); + + await page.locator(selectors.commandInput).fill("web-probe D601 v0.3 smoke: reply with exactly OK."); + await expect(page.locator(selectors.commandSend)).toBeEnabled(); + await page.locator(selectors.commandSend).click(); + + await expect.poll(async () => ((await fakeServerState(page)).chatRequests as unknown[]).length).toBe(1); + const state = await fakeServerState(page); + const request = (state.chatRequests as Record[])[0]; + if (!request) throw new Error("expected one chat request"); + expect(request.conversationId).toBe(conversationId); + expect(String(request.sessionId)).toMatch(/^ses_/u); + expect(request.threadId ?? null).toBeNull(); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts new file mode 100644 index 00000000..e6674107 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts @@ -0,0 +1,18 @@ +import { expect, gotoWorkbench, test } from "../fixtures/test"; +import { selectors, sessionTab } from "../fixtures/selectors"; + +test.use({ scenarioId: "stale-nested-trace" }); + +test("session restore ignores stale nested workspace lastTraceId", async ({ page }) => { + const staleTraceRequests: string[] = []; + page.on("request", (request) => { + if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url()); + }); + + await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench"); + await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖"); + await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible(); + await page.waitForTimeout(500); + expect(staleTraceRequests).toEqual([]); +});