fix: 收敛 workbench 会话选择权威
This commit is contained in:
@@ -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) ?? "");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user