Merge pull request #1454 from pikasTech/fix/1361-1448-1452-1453-workbench-session-authority

fix: 收敛 workbench 会话选择权威
This commit is contained in:
Lyon
2026-06-18 08:50:53 +08:00
committed by GitHub
7 changed files with 170 additions and 10 deletions
@@ -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: [] };
@@ -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) ?? "");
}
+51 -8
View File
@@ -184,6 +184,8 @@ export const useWorkbenchStore = defineStore("workbench", () => {
async function selectConversation(conversation: ConversationRecord): Promise<void> {
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<void> {
@@ -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<void> {
const conversationId = conversation.conversationId;
let response = existingResponse ?? null;
@@ -49,6 +49,7 @@ async function applyRouteConversation(): Promise<boolean> {
return await workbench.selectConversationById(conversationId);
} finally {
applyingRouteConversation.value = false;
await reflectActiveConversationInUrl(workbench.activeConversationId);
}
}
@@ -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");
});
});
@@ -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<string, unknown>[])[0];
expect(request?.conversationId).toBe("cnv_server_created");
expect(request?.sessionId).toBe("ses_server_created");
});
});
@@ -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");
});
});