fix: keep workbench session projection non-destructive

This commit is contained in:
lyon
2026-06-19 10:32:39 +08:00
parent acbcca7a5b
commit 37ad8b7623
3 changed files with 50 additions and 7 deletions
@@ -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;
+16 -7
View File
@@ -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 {
@@ -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" });