From 88ec2dadea0bd9e403afc6c47074815b5f77465f Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 18 Jun 2026 17:41:15 +0800 Subject: [PATCH] fix: converge workbench submit authority --- .../scripts/workbench-e2e-server.ts | 33 ++++++++ .../src/router/workbench-navigation.ts | 8 +- .../src/stores/workbench-session.ts | 4 +- web/hwlab-cloud-web/src/stores/workbench.ts | 82 +++++++++++-------- .../workbench-e2e/specs/deep-link.spec.ts | 9 ++ .../specs/submit-authority.spec.ts | 35 ++++++++ 6 files changed, 133 insertions(+), 38 deletions(-) create mode 100644 web/hwlab-cloud-web/tests/workbench-e2e/specs/submit-authority.spec.ts diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 310bc1ad..1446ff88 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -28,6 +28,8 @@ interface ScenarioState { chatRequests: JsonRecord[]; listOmitSelected: boolean; sessionDelayMs: number; + sessionDetailDelayMs: number; + chatDelayMs: number; terminalScript: boolean; terminalFailureScript: boolean; staleTraceId: string | null; @@ -101,6 +103,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u); if (sessionMessagesMatch && method === "GET") { + await delay(state.sessionDetailDelayMs); const sessionId = canonicalSessionId(decodeURIComponent(sessionMessagesMatch[1] ?? "")); const session = visibleSessionById(sessionId); return session ? json(response, 200, workbenchSessionMessagesPayload(session, url)) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } }); @@ -108,6 +111,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) const workbenchSessionMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u); if (workbenchSessionMatch && method === "GET") { + await delay(state.sessionDetailDelayMs); const sessionId = canonicalSessionId(decodeURIComponent(workbenchSessionMatch[1] ?? "")); 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" } }); @@ -118,6 +122,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (path === "/v1/agent/chat" && method === "POST") { const body = await readJson(request); state.chatRequests.push(redactRequestBody(body)); + await delay(state.chatDelayMs); return json(response, 200, acceptChatTurn(body)); } @@ -233,6 +238,10 @@ function createScenarioState(scenarioId: string): ScenarioState { sessions.unshift(legacyCanonicalSession()); traces.trc_40badc15523146c9 = legacyCanonicalTrace(); } + if (id === "submit-authority-race") { + sessions.unshift(submitSplitSession()); + traces.trc_submit_split = submitSplitTrace(); + } if (id === "session-rail-many") { for (let index = 0; index < 46; index += 1) sessions.push(manyRailSession(index)); } @@ -267,6 +276,8 @@ function createScenarioState(scenarioId: string): ScenarioState { chatRequests: [], listOmitSelected: id === "selected-missing-from-list", sessionDelayMs: id === "loading" ? 2_500 : 0, + sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : 0, + chatDelayMs: id === "submit-authority-race" ? 1_000 : 0, terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore", terminalFailureScript: id === "stale-submit-restore", staleTraceId @@ -362,6 +373,28 @@ function archivedDeletedSession(): SessionRecord { return { sessionId: "ses_deleted", threadId: "thr_deleted", status: "archived", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已删除 session", messages: [] }; } +function submitSplitSession(): SessionRecord { + const now = new Date().toISOString(); + return { + sessionId: "ses_submit_split", + threadId: "thr_submit_split", + status: "idle", + lastTraceId: "trc_submit_split", + startedAt: now, + updatedAt: now, + messageCount: 2, + firstUserMessagePreview: "workbench submit race probe redacted: 请只回复 OK。", + messages: [ + { id: "msg_submit_split_user", messageId: "msg_submit_split_user", role: "user", title: "用户", text: "", status: "sent", createdAt: now, sessionId: "ses_submit_split", threadId: "thr_submit_split", traceId: "trc_submit_split", turnId: "turn_submit_split" }, + { id: "msg_submit_split_agent", messageId: "msg_submit_split_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_submit_split", threadId: "thr_submit_split", traceId: "trc_submit_split", turnId: "turn_submit_split", runnerTrace: { traceId: "trc_submit_split", status: "running", sessionId: "ses_submit_split", threadId: "thr_submit_split", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } } + ] + }; +} + +function submitSplitTrace(): JsonRecord { + return { traceId: "trc_submit_split", status: "running", sessionId: "ses_submit_split", threadId: "thr_submit_split", turnId: "turn_submit_split", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false }; +} + function acceptChatTurn(body: JsonRecord): JsonRecord { const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : state.selectedSessionId; const session = sessionById(sessionId) ?? createManualSession({ sessionId }); diff --git a/web/hwlab-cloud-web/src/router/workbench-navigation.ts b/web/hwlab-cloud-web/src/router/workbench-navigation.ts index 17ec66af..c6351b48 100644 --- a/web/hwlab-cloud-web/src/router/workbench-navigation.ts +++ b/web/hwlab-cloud-web/src/router/workbench-navigation.ts @@ -26,6 +26,12 @@ export function shouldReflectWorkbenchSessionUrl(input: WorkbenchNavigationConte if (!input.sessionId) return false; if (!isWorkbenchNavigationContext(input)) return false; if (input.routeSessionId === input.sessionId) return false; - if (input.applyingRouteSession && input.routeSessionId && input.routeSessionId !== input.sessionId) return false; + if (input.applyingRouteSession && input.routeSessionId && input.routeSessionId !== input.sessionId && !isCanonicalRoutePair(input.routeSessionId, input.sessionId)) return false; return true; } + +function isCanonicalRoutePair(routeSessionId: string | null | undefined, sessionId: string | null | undefined): boolean { + if (!routeSessionId || !sessionId) return false; + if (!routeSessionId.startsWith("cnv_") || !sessionId.startsWith("ses_")) return false; + return routeSessionId.slice("cnv_".length) === sessionId.slice("ses_".length); +} diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index a04f3e87..73873171 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -76,7 +76,9 @@ export function resolveComposerState(input: { messages: ChatMessage[]; sessions? const latestTraceId = firstNonEmptyString(latestMessage?.traceId, latestMessage?.runnerTrace?.traceId); const activeTraceId = firstNonEmptyString(currentRequest?.traceId, latestTraceId); const turn = activeTraceId ? input.turnStatusAuthority?.[activeTraceId] : null; - const activeByStatus = turn?.running === true || isActiveStatus(turn?.status); + const activeByRequest = Boolean(currentRequest && isActiveStatus(currentRequest.status ?? "running")); + const activeByMessage = latestMessage?.role === "agent" && isActiveStatus(latestMessage.status); + const activeByStatus = turn?.running === true || isActiveStatus(turn?.status) || activeByRequest || activeByMessage; const terminal = turn?.terminal === true || isTerminalStatus(turn?.status); const active = activeSession(input.sessions ?? [], sessionId); const effectiveSessionId = firstNonEmptyString(turn?.sessionId, currentRequest?.sessionId, active?.sessionId, sessionId); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index c7e6c9e8..d4ae0d14 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -236,33 +236,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { void source; } - async function refreshSelectedSessionStatus(fallbackSession?: WorkbenchSessionRecord | null): Promise { - await refreshSessionStatusById(firstNonEmptyString(fallbackSession?.sessionId, selectedSessionId.value)); - } - - async function refreshSessionStatusById(sessionId: string | null | undefined): Promise { - const id = firstNonEmptyString(sessionId); - if (!id) return; - const response = await api.agent.getAgentSession(id); - const session = response.data?.session && typeof response.data.session === "object" ? response.data.session : null; - if (!response.ok || !session) { - reduceServerState({ type: "session.forget", sessionId: id }); - return; - } - const returnedSessionId = firstNonEmptyString(session.sessionId, id); - if (returnedSessionId !== id) return; - reduceServerState({ - type: "session.status", - session: { - sessionId: id, - status: firstNonEmptyString(session.status) ?? null, - updatedAt: firstNonEmptyString(session.updatedAt) ?? null, - lastTraceId: firstNonEmptyString(session.lastTraceId) ?? null, - loadedAt: new Date().toISOString() - } - }); - } - async function hydrateTurnStatusAuthority(source: ChatMessage[] = messages.value): Promise { await Promise.all(uniqueTraceIds(source).slice(-12).map((traceId) => refreshTurnStatusByTraceId(traceId))); } @@ -342,9 +315,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { const pending = makeMessage("agent", "", "running", { traceId, sessionId, threadId, title: "Code Agent", retryInput: value, traceAutoLifecycle: "running" }); messages.value.push(user, pending); startWorkbenchSubmitJourney({ traceId, sessionId, entry: submitEntry, backend: providerProfile.value, transport: "sse" }); - void refreshSessionStatusById(sessionId); chatPending.value = true; currentRequest.value = { traceId, sessionId, threadId, status: "running" }; + projectOptimisticRunningTurn({ sessionId, threadId, traceId, userText: value }); rememberRecentDraft(value); recordActivity("submit"); const payload = { message: value, prompt: value, sessionId, threadId: providerThreadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, targetTraceId: composer.value.targetTraceId, steerTraceId }; @@ -356,11 +329,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (steerMode && response.status === 404) void clearActiveTrace(traceId, "steer-trace-not-found"); chatPending.value = false; currentRequest.value = null; - void refreshSessionStatusById(sessionId); return; } markWorkbenchSubmitApiAccepted(traceId); - void refreshSessionStatusById(sessionId); alignOptimisticTurnMessages(traceId, response.data); applyTurnStatusSnapshot(traceId, response.data); void refreshSessions(sessionId); @@ -381,7 +352,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { void clearActiveTrace(traceId, "cancel-agent-message"); if (message.status === "running") chatPending.value = false; currentRequest.value = null; - void refreshSessionStatusById(message.sessionId ?? selectedSessionId.value); + void refreshSessions(message.sessionId ?? selectedSessionId.value); } async function cancelRunningTrace(): Promise { @@ -701,7 +672,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { return { ...message, status, text: nextText, traceAutoLifecycle: terminal ? "terminal" : "running", runnerTrace, error: error ?? message.error ?? null, updatedAt: new Date().toISOString() }; }); markWorkbenchTraceProjected(traceId); - void refreshSessionStatusById(trace.sessionId ?? selectedSessionId.value); + void refreshSessions(trace.sessionId ?? selectedSessionId.value); } function completeTrace(traceId: string, result: AgentChatResultResponse): void { @@ -723,7 +694,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { chatPending.value = false; currentRequest.value = null; void clearActiveTrace(traceId, "trace-terminal"); - void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value); void refreshSessions(result.sessionId ?? activeSessionId.value); restartRealtime("trace-terminal"); } @@ -751,7 +721,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { return { ...message, status, title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); void hydrateTraceEvents(messages.value); - void refreshSessionStatusById(result.sessionId ?? selectedSessionId.value); void refreshSessions(result.sessionId ?? activeSessionId.value); } @@ -760,7 +729,26 @@ export const useWorkbenchStore = defineStore("workbench", () => { chatPending.value = false; currentRequest.value = null; void clearActiveTrace(traceId, "trace-infrastructure-error"); - void refreshSelectedSessionStatus(); + void refreshSessions(selectedSessionId.value); + } + + function projectOptimisticRunningTurn(input: { sessionId: string; threadId: string | null; traceId: string; userText: string }): void { + const now = new Date().toISOString(); + reduceServerState({ type: "turn.status", turn: { traceId: input.traceId, status: "running", running: true, terminal: false, sessionId: input.sessionId, threadId: input.threadId, updatedAt: now, loadedAt: now } }); + reduceServerState({ type: "session.status", session: { sessionId: input.sessionId, status: "running", updatedAt: now, lastTraceId: input.traceId, loadedAt: now } }); + const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null; + sessions.value = mergeSessionIntoList(sessions.value, { + ...(existing ?? {}), + sessionId: input.sessionId, + threadId: firstNonEmptyString(input.threadId, existing?.threadId), + status: "running", + updatedAt: now, + lastTraceId: input.traceId, + firstUserMessagePreview: firstNonEmptyString(existing?.firstUserMessagePreview, input.userText), + messageCount: Math.max(existing?.messageCount ?? 0, messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId).length), + messages: messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId) + } as WorkbenchSessionRecord); + rememberSessionList(sessions.value); } function markMessage(traceId: string, patch: Partial): void { @@ -894,10 +882,32 @@ async function loadWorkbenchSession(sessionId: string, seed: WorkbenchSessionRec const base = detailSession ?? seed; if (!base) return null; const page = messages.ok ? messages.data : null; - const pageMessages = Array.isArray(page?.messages) ? page.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : base.messages; + const pageMessages = Array.isArray(page?.messages) ? repairWorkbenchMessagePage(page.messages.map((message) => normalizeChatMessage(message as ChatMessage)), base) : base.messages; return { ...base, sessionId: id, messages: pageMessages, messageCount: page?.total ?? pageMessages?.length ?? base.messageCount }; } +function repairWorkbenchMessagePage(messages: ChatMessage[], session: WorkbenchSessionRecord): ChatMessage[] { + const userFallback = firstNonEmptyString(session.firstUserMessagePreview, session.snapshot?.firstUserMessagePreview, session.userPreview, session.title, session.name); + return messages.map((message, index) => { + if (message.role !== "user" || firstNonEmptyString(message.text)) return message; + const matched = findMessageTextFallback(message, index, session.messages ?? []); + const text = firstNonEmptyString(matched, index === 0 ? userFallback : null); + return text ? { ...message, text } : message; + }); +} + +function findMessageTextFallback(message: ChatMessage, index: number, candidates: ChatMessage[]): string | null { + const messageId = firstNonEmptyString(message.messageId, message.id); + const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); + const turnId = firstNonEmptyString(message.turnId); + const matched = candidates.find((candidate) => candidate.role === message.role && Boolean( + (messageId && firstNonEmptyString(candidate.messageId, candidate.id) === messageId) + || (traceId && firstNonEmptyString(candidate.traceId, candidate.runnerTrace?.traceId) === traceId) + || (turnId && firstNonEmptyString(candidate.turnId) === turnId) + )); + return firstNonEmptyString(matched?.text, candidates.filter((candidate) => candidate.role === message.role)[index]?.text); +} + function providerThreadIdForRequest(threadId: string | null | undefined): string | null { const value = firstNonEmptyString(threadId) ?? null; if (!value) return null; diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts index e5fd89bb..72fe969d 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/deep-link.spec.ts @@ -41,4 +41,13 @@ test.describe("legacy cnv deep link canonical authority", () => { expect(detailRequests.some((item) => item.path === "/v1/workbench/sessions/ses_running")).toBe(false); expect((state.legacyRequestLedger as unknown[]).length).toBe(0); }); + + test("cnv route reflects canonical URL as soon as canonical content is visible", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/cnv_c5e1330558c94d8e"); + await expect(page.locator(selectors.sessionStatus)).toContainText("当前 ses_c5e1330558c94d8e"); + await expect(page).toHaveURL(/\/workbench\/sessions\/ses_c5e1330558c94d8e$/u, { timeout: 500 }); + const state = await fakeServerState(page); + const legacyGets = (state.requestLedger as Array<{ method?: string; path?: string }>).filter((item) => item.method === "GET" && /^\/v1\/agent\/sessions\/[^/]+$/u.test(item.path ?? "")); + expect(legacyGets).toEqual([]); + }); }); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/submit-authority.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/submit-authority.spec.ts new file mode 100644 index 00000000..3905a545 --- /dev/null +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/submit-authority.spec.ts @@ -0,0 +1,35 @@ +import { expect, fakeServerState, gotoWorkbench, test } from "../fixtures/test"; +import { selectors, sessionTab } from "../fixtures/selectors"; + +test.describe("submit session authority", () => { + test.use({ scenarioId: "submit-authority-race" }); + + test("optimistic submit immediately projects running state to tab and composer without legacy session detail", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/ses_running"); + await page.locator(selectors.commandInput).fill("workbench submit race probe redacted: 请只回复 OK。"); + await expect(page.locator(selectors.commandSend)).toBeEnabled(); + await page.locator(selectors.commandSend).click(); + await page.waitForTimeout(250); + + await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-running", "true"); + await expect(page.locator(sessionTab("ses_running"))).toHaveAttribute("data-status", /^(running|pending)$/u); + await expect(page.locator(`${selectors.messageCard}[data-role="user"]`).last()).toContainText("workbench submit race probe redacted"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last()).toBeVisible(); + await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel"); + + const state = await fakeServerState(page); + const legacyGets = (state.requestLedger as Array<{ method?: string; path?: string }>).filter((item) => item.method === "GET" && /^\/v1\/agent\/sessions\/[^/]+$/u.test(item.path ?? "")); + expect(legacyGets).toEqual([]); + }); + + test("replay preserves user prompt when messages page has empty user text but list preview has authority", async ({ page }) => { + await gotoWorkbench(page, "/workbench/sessions/ses_submit_split"); + await expect(page.locator(sessionTab("ses_submit_split"))).toContainText("workbench submit race probe redacted"); + await expect(page.locator(`${selectors.messageCard}[data-role="user"]`).first()).toContainText("workbench submit race probe redacted"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible(); + + const state = await fakeServerState(page); + const legacyGets = (state.requestLedger as Array<{ method?: string; path?: string }>).filter((item) => item.method === "GET" && /^\/v1\/agent\/sessions\/[^/]+$/u.test(item.path ?? "")); + expect(legacyGets).toEqual([]); + }); +});