From d29882c6564959a8c8da8fa0d431ee0a57861d0f Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 18 Jun 2026 08:49:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20workbench=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=80=89=E6=8B=A9=E6=9D=83=E5=A8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/workbench-e2e-server.ts | 36 ++++++++++- .../src/stores/workbench-session.ts | 7 ++- web/hwlab-cloud-web/src/stores/workbench.ts | 59 ++++++++++++++++--- .../src/views/workbench/CodeWorkbenchView.vue | 1 + .../workbench-e2e/specs/event-replay.spec.ts | 11 ++++ .../specs/new-session-thread.spec.ts | 23 ++++++++ .../specs/session-switch-reload.spec.ts | 43 ++++++++++++++ 7 files changed, 170 insertions(+), 10 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 948f8a81..33d8438c 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -90,6 +90,12 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (/^\/v1\/workbench\/workspace\/[^/]+\/select-conversation$/u.test(path) && method === "POST") { const body = await readJson(request); state.selectRequests.push(redactRequestBody(body)); + if (state.scenarioId === "server-authoritative-create" && body.create === true) { + const conversationId = "cnv_server_created"; + if (!conversationById(conversationId)) state.conversations.unshift(createConversationFromSelect({ ...body, sessionId: "ses_server_created", threadId: null }, conversationId)); + state.selectedConversationId = conversationId; + return json(response, 200, { workspace: workspacePayload() }); + } 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; @@ -112,7 +118,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) } const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u); if (conversationMatch && method === "GET") { - const conversation = conversationById(decodeURIComponent(conversationMatch[1] ?? "")); + const conversationId = decodeURIComponent(conversationMatch[1] ?? ""); + if (state.scenarioId === "session-switch-detail-404-isolated" && conversationId === "cnv_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } }); + if (state.scenarioId === "completed-replay-detail-404" && conversationId === "cnv_completed") return json(response, 404, { ok: false, status: 404, error: { code: "conversation_replay_unavailable" } }); + const conversation = conversationById(conversationId); if (conversation && conversationProjectMismatch(conversation, url)) return json(response, 404, { ok: false, status: 404, error: { code: "conversation_project_mismatch" } }); return conversation ? json(response, 200, { conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } }); } @@ -124,6 +133,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const turnMatch = path.match(/^\/v1\/agent\/turns\/([^/]+)$/u); if (turnMatch && method === "GET") { const traceId = decodeURIComponent(turnMatch[1] ?? ""); + if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "turn_replay_unavailable" } }); if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } }); return json(response, 200, turnPayload(traceId)); @@ -132,6 +142,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u); if (traceMatch && method === "GET") { const traceId = decodeURIComponent(traceMatch[1] ?? ""); + if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } }); if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } }); if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } }); return json(response, 200, tracePayload(traceId, url)); @@ -156,6 +167,10 @@ function createScenarioState(scenarioId: string): ScenarioState { conversations.push(crossProjectConversation()); traces.trc_cross_project = crossProjectTrace(); } + if (id === "session-switch-detail-404-isolated") { + conversations.unshift(staleDetailConversation()); + conversations.push(emptyConversation()); + } if (id === "terminal-empty-trace") { conversations.push(terminalEmptyTraceConversation()); traces.trc_terminal_empty = terminalEmptyTrace(); @@ -163,6 +178,8 @@ function createScenarioState(scenarioId: string): ScenarioState { if (id === "session-switch-empty-reload") conversations.push(emptyConversation()); const selectedConversationId = id === "deep-link" || id === "stale-nested-trace" ? "cnv_failed" + : id === "session-switch-detail-404-isolated" || id === "completed-replay-detail-404" + ? "cnv_completed" : id === "terminal-turn-stale-session-active" ? "cnv_completed" : id === "terminal-empty-trace" @@ -316,6 +333,23 @@ function terminalEmptyTrace(): JsonRecord { return { traceId: "trc_terminal_empty", status: "completed", conversationId: "cnv_terminal_empty", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false, finalResponse: { text: "终态空 Trace 已完成。" } }; } +function staleDetailConversation(): ConversationRecord { + const now = new Date().toISOString(); + return { + conversationId: "cnv_stale_404", + projectId: state?.projectId ?? capture.scenario.projectId, + sessionId: "ses_stale_404", + threadId: "thr_stale_404", + status: "active", + startedAt: now, + updatedAt: now, + messageCount: 0, + firstUserMessagePreview: "已失效的列表项", + session: { sessionId: "ses_stale_404", threadId: "thr_stale_404", status: "active" }, + messages: [] + }; +} + function emptyConversation(): ConversationRecord { const now = new Date().toISOString(); return { conversationId: "cnv_empty", projectId: state?.projectId ?? capture.scenario.projectId, sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", session: { sessionId: "ses_empty", threadId: "thr_empty", status: "active" }, messages: [] }; diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 71baf4c3..b66173ee 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -150,7 +150,7 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ const status = resolveSessionTabStatus(conversation, sessionId ? sessionStatusAuthority[sessionId] : null); const trace = firstNonEmptyString(latestAgentMessage(conversation.messages)?.traceId, conversation.messages?.at(-1)?.traceId, conversation.lastTraceId); const userMessage = conversation.messages?.find((message) => message.role === "user"); - const running = isActiveStatus(status); + const running = isRunningIndicatorStatus(status); const preview = firstReadableSentence( conversation.firstUserMessagePreview, conversation.snapshot?.firstUserMessagePreview, @@ -320,6 +320,11 @@ function isActiveStatus(value: unknown): boolean { return ["active", "running", "pending", "accepted", "processing", "busy", "creating"].includes(status ?? ""); } +function isRunningIndicatorStatus(value: unknown): boolean { + const status = normalizeSessionStatus(value); + return ["running", "pending", "accepted", "processing", "busy"].includes(status ?? ""); +} + function isTerminalStatus(value: unknown): boolean { return ["completed", "failed", "blocked", "timeout", "canceled", "stale", "thread-resume-failed", "interrupted", "expired"].includes(normalizeSessionStatus(value) ?? ""); } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index afcb60cb..5bd133a1 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -184,6 +184,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function selectConversation(conversation: ConversationRecord): Promise { const current = await ensureWorkspace(); if (!current) return; + const previousConversationId = activeConversationId.value; + const previousMessages = messages.value; const requestEpoch = beginWorkspaceSelection(); startWorkbenchSessionSwitch({ conversationId: conversation.conversationId, source: "rail", targetState: conversation.status, cache: (conversation.messages?.length ?? 0) > 0 ? "warm" : "cold" }); const tabProjectId = conversation.projectId ?? activeProjectId.value; @@ -215,9 +217,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { finishWorkbenchSessionSwitchFullLoad(conversation.conversationId, "ok"); return; } - error.value = detailResponse.error ?? "conversation unavailable"; if (response.status === 404 && await retrySelectConversationWithFreshWorkspace(conversation, tabProjectId, requestEpoch)) return; - error.value = response.error ?? "session switch failed"; + isolateConversationLoadFailure(conversation.conversationId, previousConversationId, previousMessages, detailResponse.error ?? response.error ?? "conversation unavailable"); failWorkbenchSessionSwitch(conversation.conversationId); } @@ -227,6 +228,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { error.value = "invalid session URL"; return false; } + const previousConversationId = activeConversationId.value; + const previousMessages = messages.value; setActiveConversationSelection(normalized); const current = await ensureWorkspace(); if (!current) return false; @@ -249,7 +252,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!response.ok || !conversation) { if (isCurrentWorkspaceSelection(requestEpoch, normalized)) clearSwitchingConversation(normalized); clearConversationDetailLoading(normalized); - error.value = response.error ?? "session URL not found"; + isolateConversationLoadFailure(normalized, previousConversationId, previousMessages, response.error ?? "session URL not found"); failWorkbenchSessionSwitch(normalized); return false; } @@ -262,7 +265,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { void persistSelectedConversation(conversation, selectedProjectId); void refreshConversations(normalized); finishWorkbenchSessionSwitchFullLoad(normalized, "ok"); - return activeConversationId.value === normalized && messages.value.length > 0; + return activeConversationId.value === normalized; } async function deleteCurrentSession(): Promise { @@ -327,12 +330,24 @@ export const useWorkbenchStore = defineStore("workbench", () => { return { ok: false, error: response.error ?? "session create failed" }; } const persistedWorkspace = response.data.workspace; - const persistedConversation = persistedWorkspace.selectedConversation?.conversationId === conversation.conversationId + const persistedSelectedId = selectedConversationIdFromWorkspace(persistedWorkspace) ?? conversation.conversationId; + let persistedConversation = persistedWorkspace.selectedConversation?.conversationId ? persistedWorkspace.selectedConversation - : conversation; + : null; + if ((!persistedConversation || persistedConversation.conversationId !== persistedSelectedId) && persistedSelectedId !== conversation.conversationId) { + const detail = await api.workbench.conversation(persistedSelectedId, { projectId: selectedProjectId }); + persistedConversation = detail.ok ? detail.data?.conversation ?? persistedConversation : persistedConversation; + } + persistedConversation = persistedConversation ?? conversation; + const authoritativeConversationId = persistedConversation.conversationId; + if (authoritativeConversationId !== conversation.conversationId) { + pendingSessionCreates.delete(conversation.conversationId); + conversations.value = conversations.value.filter((item) => item.conversationId !== conversation.conversationId); + } rememberConversationDetail(persistedConversation); conversations.value = mergeConversationIntoList(conversations.value, persistedConversation); - if (activeConversationId.value === conversation.conversationId) { + if (activeConversationId.value === conversation.conversationId || activeConversationId.value === authoritativeConversationId) { + replaceActiveConversationSelection(authoritativeConversationId); const persistedProjectId = conversationProjectId(persistedConversation, selectedProjectId); workspace.value = workspaceWithSelectedConversation(persistedWorkspace, persistedConversation, persistedProjectId); rememberWorkspaceSnapshot(persistedProjectId, workspace.value); @@ -785,7 +800,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function applyRealtimeWorkspaceSnapshot(nextWorkspace: WorkspaceRecord): void { - if (!shouldApplyWorkspaceSnapshot({ requestEpoch: workspaceSelectionEpoch.value, currentEpoch: workspaceSelectionEpoch.value, currentConversationId: activeConversationId.value, workspace: nextWorkspace })) return; + if (!workspaceSnapshotTargetsActiveConversation(nextWorkspace)) return; const previousTraceId = realtimeTraceId(); workspace.value = nextWorkspace; rememberWorkspaceSnapshot(workspaceProjectId(nextWorkspace, activeProjectId.value), nextWorkspace); @@ -1041,6 +1056,34 @@ export const useWorkbenchStore = defineStore("workbench", () => { restartRealtime("apply-selected-conversation"); } + function isolateConversationLoadFailure(conversationId: string, previousConversationId: string | null, previousMessages: ChatMessage[], message: string): void { + const targetId = normalizeWorkbenchConversationId(conversationId); + const previousId = normalizeWorkbenchConversationId(previousConversationId); + if (targetId) conversations.value = conversations.value.filter((item) => item.conversationId !== targetId || (item.messages?.length ?? 0) > 0); + if (previousId && previousId !== targetId) { + const previousConversation = conversations.value.find((item) => item.conversationId === previousId) ?? null; + replaceActiveConversationSelection(previousId); + if (previousConversation) { + const previousProjectId = conversationProjectId(previousConversation, activeProjectId.value); + workspace.value = workspaceWithSelectedConversation(workspace.value, previousConversation, previousProjectId); + rememberWorkspaceSnapshot(previousProjectId, workspace.value); + } + messages.value = previousMessages; + } else { + messages.value = []; + } + currentRequest.value = null; + error.value = message; + conversationsReady.value = conversations.value.length > 0; + restartRealtime("conversation-load-failed"); + } + + function workspaceSnapshotTargetsActiveConversation(nextWorkspace: WorkspaceRecord): boolean { + const currentConversationId = activeConversationId.value; + if (!currentConversationId) return true; + return selectedConversationIdFromWorkspace(nextWorkspace) === currentConversationId; + } + async function persistSelectedConversation(conversation: ConversationRecord, selectedProjectId: string, existingResponse?: ApiResult<{ workspace?: WorkspaceRecord }>): Promise { const conversationId = conversation.conversationId; let response = existingResponse ?? null; diff --git a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue index 65f9cef8..012f2643 100644 --- a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue +++ b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue @@ -49,6 +49,7 @@ async function applyRouteConversation(): Promise { return await workbench.selectConversationById(conversationId); } finally { applyingRouteConversation.value = false; + await reflectActiveConversationInUrl(workbench.activeConversationId); } } diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts index 86548d30..8b5c4175 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts @@ -17,3 +17,14 @@ test("SSE terminal event and REST gap fill replay to the same terminal UI", asyn await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("事件重放后完成"); await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible(); }); + +test.describe("completed replay unavailable", () => { + test.use({ scenarioId: "completed-replay-detail-404" }); + + test("detail or turn 404 renders diagnostic state instead of an empty conversation", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/cnv_completed?projectId=prj_hwpod_workbench"); + await expect(page.locator(".conversation-error-hint")).toContainText(/conversation|replay|unavailable/u); + await expect(page.locator(selectors.messageCard)).toHaveCount(0); + await expect(page.locator(selectors.sessionTabs)).not.toHaveAttribute("data-loading", "true"); + }); +}); 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 index 47da11cb..26a0e1c7 100644 --- 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 @@ -21,3 +21,26 @@ test("new optimistic session first turn does not submit local thr_* as upstream expect(String(request.sessionId)).toMatch(/^ses_/u); expect(request.threadId ?? null).toBeNull(); }); + +test.describe("server authoritative session create", () => { + test.use({ scenarioId: "server-authoritative-create" }); + + test("new session adopts backend selected conversation before submit", async ({ page }) => { + await gotoWorkbench(page); + await page.locator(selectors.sessionCreate).click(); + + await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_server_created/u); + await expect(page.locator('.session-tab[data-conversation-id="cnv_server_created"]')).toHaveAttribute("data-active", "true"); + await expect(page.locator('.session-tab[data-conversation-id="cnv_server_created"]')).toHaveAttribute("data-running", "false"); + + await page.locator(selectors.commandInput).fill("server authoritative create smoke"); + 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]; + expect(request?.conversationId).toBe("cnv_server_created"); + expect(request?.sessionId).toBe("ses_server_created"); + }); +}); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts index f50bdd84..d000623b 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/session-switch-reload.spec.ts @@ -39,4 +39,47 @@ test.describe("empty session switch target", () => { await expect(page.locator(selectors.messageCard)).toHaveCount(0); await saveScreenshot(page, testInfo, "session-switch-empty-reload"); }); + + test("switching back to a running session reflects URL before reload", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/cnv_running?projectId=prj_hwpod_workbench"); + await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible(); + + await page.locator(sessionTab("cnv_empty")).click(); + await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_empty/u); + await expect(page.locator(selectors.messageCard)).toHaveCount(0); + + await page.locator(sessionTab("cnv_running")).click(); + await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_running/u); + await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible(); + + await page.reload(); + await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible(); + }); +}); + +test.describe("stale session detail isolation", () => { + test.use({ scenarioId: "session-switch-detail-404-isolated" }); + + test("a visible tab with detail 404 does not clear rail or block later switches", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/cnv_completed?projectId=prj_hwpod_workbench"); + await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(1); + + await page.locator(sessionTab("cnv_stale_404")).click(); + await expect(page.locator(sessionTab("cnv_completed"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(sessionTab("cnv_stale_404"))).toHaveCount(0); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toBeVisible(); + + await page.locator(sessionTab("cnv_empty")).click(); + await expect(page).toHaveURL(/\/workbench\/sessions\/cnv_empty/u); + await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(selectors.messageCard)).toHaveCount(0); + + await page.reload(); + await expect(page.locator(sessionTab("cnv_empty"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(selectors.sessionTabs)).not.toHaveAttribute("data-loading", "true"); + }); });