From 37ad8b762347bd8f5320e561c8e0d1866d5ee4a9 Mon Sep 17 00:00:00 2001 From: lyon Date: Fri, 19 Jun 2026 10:32:39 +0800 Subject: [PATCH] fix: keep workbench session projection non-destructive --- .../scripts/workbench-e2e-server.ts | 4 +++ web/hwlab-cloud-web/src/stores/workbench.ts | 23 +++++++++----- .../specs/new-session-thread.spec.ts | 30 +++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index ff890823..a50fb64d 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -126,6 +126,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (sessionMessagesMatch && method === "GET") { const sessionId = canonicalSessionId(decodeURIComponent(sessionMessagesMatch[1] ?? "")); await delay(sessionDetailDelayMs(sessionId)); + if (state.scenarioId === "created-session-read-failure" && sessionId === "ses_created_read_failure") return json(response, 503, { ok: false, status: 503, error: { code: "session_messages_unavailable" } }); const session = visibleSessionById(sessionId); return session ? json(response, 200, workbenchSessionMessagesPayload(session, url)) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); } @@ -134,6 +135,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (workbenchSessionMatch && method === "GET") { const sessionId = canonicalSessionId(decodeURIComponent(workbenchSessionMatch[1] ?? "")); await delay(sessionDetailDelayMs(sessionId)); + if (state.scenarioId === "created-session-read-failure" && sessionId === "ses_created_read_failure") return json(response, 503, { ok: false, status: 503, error: { code: "session_detail_unavailable" } }); if (state.scenarioId === "session-switch-detail-404-isolated" && sessionId === "ses_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); if (state.scenarioId === "completed-replay-detail-404" && sessionId === "ses_completed") return json(response, 404, { ok: false, status: 404, error: { code: "session_replay_unavailable" } }); const session = visibleSessionById(sessionId, { includeArchived: true }); @@ -517,6 +519,8 @@ function createManualSession(body: JsonRecord): SessionRecord { ? "server_created" : state.scenarioId === "create-while-route-loading" ? "created_while_running" + : state.scenarioId === "created-session-read-failure" + ? "created_read_failure" : Date.now().toString(36); 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; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 4aafa3f0..ced17228 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -118,8 +118,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { const selectedIsCurrent = Boolean(selected && !isArchivedSession(selected) && isCurrentSessionSelection(requestEpoch, selected.sessionId)); if (selectedIsCurrent && selected) applySelectedSessionDetail(selected, "route"); if (selected && !isArchivedSession(selected) && routeRequestId && !isCurrentSessionSelection(requestEpoch, selected.sessionId) && selected.sessionId !== activeSessionId.value) rememberSessionDetail(selected); - if (selected && isArchivedSession(selected)) isolateSessionLoadFailure(selected.sessionId, "session archived"); - if (options.invalidRouteId || (targetSessionId && !selected)) isolateSessionLoadFailure(targetSessionId ?? options.invalidRouteId ?? "invalid-session", "session URL not found"); + if (selected && isArchivedSession(selected)) applyCanonicalSessionArchive(selected.sessionId, "session archived"); + if (options.invalidRouteId) markSessionReadUnavailable(options.invalidRouteId, "invalid session URL"); + if (targetSessionId && !selected) markSessionReadUnavailable(targetSessionId, "session detail unavailable"); const stableSelected = selectedIsCurrent && selected ? selected : activeSessionListSeed(); const nextSessions = stableSessionList(sessions.value, listedSessions, stableSelected?.sessionId ?? selected?.sessionId ?? routeSessionId ?? includeSessionId, stableSelected); rememberSessionList(nextSessions); @@ -204,7 +205,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { loading.value = false; clearSessionDetailLoading(requestId); clearSwitchingSession(requestId); - isolateSessionLoadFailure(requestId, isArchivedSession(selected) ? "session archived" : "session URL not found"); + if (selected && isArchivedSession(selected)) applyCanonicalSessionArchive(selected.sessionId, "session archived"); + else markSessionReadUnavailable(requestId, "session detail unavailable"); failWorkbenchSessionSwitch(normalized ?? requestId); return false; } @@ -1020,13 +1022,20 @@ export const useWorkbenchStore = defineStore("workbench", () => { restartRealtime("apply-selected-session"); } - function isolateSessionLoadFailure(sessionId: string, message: string): void { + function applyCanonicalSessionArchive(sessionId: string, message: string): void { forgetSession(sessionId); - replaceActiveSessionSelection(null); - currentRequest.value = null; + if (activeSessionId.value === sessionId) replaceActiveSessionSelection(null); + if (currentRequest.value?.sessionId === sessionId) currentRequest.value = null; error.value = message; sessionsReady.value = sessions.value.length > 0; - restartRealtime("session-load-failed"); + restartRealtime("session-archived"); + } + + function markSessionReadUnavailable(sessionId: string, message: string): void { + void sessionId; + error.value = message; + sessionsReady.value = sessions.value.length > 0; + restartRealtime("session-read-unavailable"); } function reattachRestoredActiveTrace(): void { 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 ff4a0124..10e4ac72 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 @@ -72,6 +72,36 @@ test.describe("session create while old route is still loading", () => { }); }); +test.describe("session create read-side failure authority", () => { + test.use({ scenarioId: "created-session-read-failure" }); + + test("created session keeps active projection when detail/messages read fails", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/ses_running"); + + const created = page.waitForResponse((response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/v1/agent/sessions"); + await page.locator(selectors.sessionCreate).click(); + expect((await created).status()).toBe(200); + + const createdTab = page.locator(sessionTab("ses_created_read_failure")); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_created_read_failure/u); + await expect(createdTab).toHaveAttribute("data-active", "true"); + await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-active", "false"); + await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-running", "true"); + await expect(page.locator(".conversation-error-hint")).toContainText(/unavailable/u); + await expect(page.locator(".composer-mode")).toContainText("turn / 新请求"); + await expect(page.locator(".composer-warning")).toHaveCount(0); + + await page.locator(selectors.commandInput).fill("created session projection must survive read failure"); + await expect(page.locator(selectors.commandSend)).toBeEnabled(); + await page.waitForTimeout(800); + await expect(createdTab).toHaveAttribute("data-active", "true"); + await expect(page.locator(".composer-mode")).toContainText("turn / 新请求"); + + const state = await fakeServerState(page); + expect((state.legacyRequestLedger as unknown[]).length).toBe(0); + }); +}); + test.describe("session create and delete authority", () => { test.use({ scenarioId: "server-authoritative-create" });