From 4b0e6480d53520fd4f3ac75d4b51d45919c61d61 Mon Sep 17 00:00:00 2001 From: lyon Date: Thu, 18 Jun 2026 20:15:44 +0800 Subject: [PATCH] fix: project workbench status from current turn --- internal/cloud/server-workbench-http.test.ts | 83 +++++++++++++++++- internal/cloud/server-workbench-http.ts | 84 ++++++++++--------- .../scripts/workbench-navigation.test.ts | 2 +- .../scripts/workbench-server-state.test.ts | 23 ++++- .../src/stores/workbench-session.ts | 17 +--- web/hwlab-cloud-web/src/stores/workbench.ts | 4 +- 6 files changed, 150 insertions(+), 63 deletions(-) diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index 22ad3308..d34d7dfb 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -293,7 +293,8 @@ test("workbench read model does not expose trace-only memory without visible ses } }); -test("workbench read model treats active session state as running", async () => { +test("workbench read model projects current turn running state from trace projection", async () => { + const traceStore = createCodeAgentTraceStore(); const traceId = "trc_workbench_active_state"; const session = { id: "ses_workbench_active_state", @@ -307,6 +308,7 @@ test("workbench read model treats active session state as running", async () => updatedAt: "2026-06-17T00:01:00.000Z", session: { sessionStatus: "active", lastTraceId: traceId, messages: [{ role: "user", text: "still running", traceId }] } }; + traceStore.append(traceId, { type: "backend", status: "running", label: "runner:created", terminal: false }); const accessController = { store: { async listAgentSessionsForUser() { return [session]; } @@ -314,14 +316,14 @@ test("workbench read model treats active session state as running", async () => async ensureBootstrap() {}, async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; } }; - const server = createCloudApiServer({ accessController, codeAgentChatResults: createCodeAgentChatResultStore() }); + const server = createCloudApiServer({ accessController, traceStore, codeAgentChatResults: createCodeAgentChatResultStore() }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); const sessions = await getJson(port, "/v1/workbench/sessions"); assert.equal(sessions.status, 200); - assert.equal(sessions.body.sessions[0].status, "active"); + assert.equal(sessions.body.sessions[0].status, "running"); assert.equal(sessions.body.sessions[0].running, true); assert.equal(sessions.body.sessions[0].terminal, false); } finally { @@ -329,7 +331,7 @@ test("workbench read model treats active session state as running", async () => } }); -test("workbench read model lets running trace override idle session summary", async () => { +test("workbench read model projects current turn running state for idle session summary", async () => { const traceStore = createCodeAgentTraceStore(); const traceId = "trc_workbench_idle_with_running_trace"; const session = { @@ -368,6 +370,79 @@ test("workbench read model lets running trace override idle session summary", as } }); +test("workbench read model projects completed current turn for idle session summary", async () => { + const traceStore = createCodeAgentTraceStore(); + const results = createCodeAgentChatResultStore(); + const traceId = "trc_workbench_idle_with_completed_trace"; + const finalText = "OK"; + const session = { + id: "ses_workbench_idle_with_completed_trace", + projectId: "prj_hwpod_workbench", + agentId: "hwlab-code-agent", + status: "idle", + ownerUserId: ACTOR.id, + conversationId: "cnv_workbench_idle_with_completed_trace", + threadId: "thread-workbench-idle-with-completed-trace", + lastTraceId: traceId, + updatedAt: "2026-06-17T00:03:00.000Z", + session: { + sessionStatus: "idle", + lastTraceId: traceId, + messages: [ + { role: "user", text: "reply OK", traceId, status: "sent" }, + { role: "agent", text: "", traceId, status: "running" } + ] + } + }; + traceStore.append(traceId, { type: "request", status: "accepted", label: "request:accepted" }); + traceStore.append(traceId, { type: "result", status: "completed", label: "result:completed", terminal: true }); + results.set(traceId, { + status: "completed", + traceId, + ownerUserId: ACTOR.id, + conversationId: session.conversationId, + sessionId: session.id, + threadId: session.threadId, + finalResponse: finalText, + agentRun: { runId: "run_workbench_idle_completed", commandId: "cmd_workbench_idle_completed", status: "completed" } + }); + const accessController = { + store: { + async listAgentSessionsForUser() { return [session]; }, + async getAgentSession(sessionId) { return sessionId === session.id ? session : null; }, + async getAgentSessionByTraceId(requestTraceId) { return requestTraceId === traceId ? session : null; } + }, + async ensureBootstrap() {}, + async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; } + }; + const server = createCloudApiServer({ accessController, traceStore, codeAgentChatResults: results }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const sessions = await getJson(port, `/v1/workbench/sessions?includeSessionId=${encodeURIComponent(session.id)}`); + assert.equal(sessions.status, 200); + assert.equal(sessions.body.sessions[0].status, "completed"); + assert.equal(sessions.body.sessions[0].running, false); + assert.equal(sessions.body.sessions[0].terminal, true); + assert.equal(sessions.body.sessions[0].turnSummary.status, "completed"); + + const messages = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(session.id)}/messages?limit=10`); + assert.equal(messages.status, 200); + assert.equal(messages.body.messages[1].status, "completed"); + assert.equal(messages.body.messages[1].text, finalText); + + const turn = await getJson(port, `/v1/workbench/turns/${encodeURIComponent(traceId)}`); + assert.equal(turn.status, 200); + assert.equal(turn.body.turn.status, "completed"); + assert.equal(turn.body.turn.running, false); + assert.equal(turn.body.turn.terminal, true); + assert.equal(turn.body.turn.assistantText, finalText); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + test("workbench realtime stream accepts session authority without project or workspace", async () => { const traceStore = createCodeAgentTraceStore(); const results = createCodeAgentChatResultStore(); diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index f7c8d9d3..094d049d 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -277,7 +277,7 @@ async function handleWorkbenchTurnSnapshot(response, url, options, actor, rawTur } const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId)); const trace = await traceSnapshot(options, traceId); - const status = turnStatus(result, session, trace); + const status = workbenchTurnProjectionStatus(trace); const found = Boolean(resultVisible || session); if (!found) return sendJson(response, 404, workbenchError("workbench_turn_not_found", "Workbench turn is not visible to the current actor.", { turnId, traceId })); sendJson(response, 200, { @@ -345,10 +345,9 @@ async function visibleSessionByTrace(store, traceId, actor) { function sessionSummary(session, options) { if (!session?.id) return null; const snapshot = objectValue(session.session); - const traceId = safeTraceId(session.lastTraceId ?? snapshot.lastTraceId ?? snapshot.currentTraceId) ?? null; - const trace = traceId ? traceSnapshotSync(options, traceId) : null; - const messages = sessionMessages(session, options); - const status = canonicalWorkbenchStatus(snapshot.sessionStatus ?? session.status, trace?.status); + const currentTurn = currentWorkbenchTurnProjection(session, options); + const messages = sessionMessages(session, { ...options, currentTurn }); + const status = currentTurn ? currentTurn.status : sessionLifecycleProjectionStatus(session); return { sessionId: session.id, threadId: safeOpaqueId(session.threadId) ?? (textValue(session.threadId) || null), @@ -356,19 +355,19 @@ function sessionSummary(session, options) { status, running: RUNNING_STATUSES.has(status), terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status), - lastTraceId: traceId, + lastTraceId: currentTurn ? currentTurn.traceId : null, providerProfile: textValue(snapshot.providerProfile) || null, messageCount: messages.length, firstUserMessagePreview: firstUserPreview(messages), updatedAt: session.updatedAt ?? snapshot.updatedAt ?? null, - turnSummary: traceId ? { - turnId: traceId, - traceId, + turnSummary: currentTurn ? { + turnId: currentTurn.turnId, + traceId: currentTurn.traceId, status, running: RUNNING_STATUSES.has(status), terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status), - eventCount: trace?.eventCount ?? null, - updatedAt: trace?.updatedAt ?? session.updatedAt ?? null + eventCount: currentTurn.eventCount, + updatedAt: currentTurn.updatedAt } : null, valuesRedacted: true }; @@ -391,8 +390,9 @@ function sessionDetail(session, options) { function sessionMessages(session, options = {}) { const snapshot = objectValue(session.session); const raw = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : []; - const finalTraceId = safeTraceId(session.lastTraceId ?? snapshot.lastTraceId ?? snapshot.currentTraceId) ?? null; - const terminalProjection = terminalMessageProjection(session, snapshot, options, finalTraceId); + const currentTurn = options.currentTurn ? options.currentTurn : currentWorkbenchTurnProjection(session, options); + const finalTraceId = currentTurn ? currentTurn.traceId : null; + const terminalProjection = terminalMessageProjection(snapshot, options, currentTurn); const messages = raw .map((message, index) => messageFact(message, index, session, snapshot)) .filter(Boolean) @@ -400,22 +400,22 @@ function sessionMessages(session, options = {}) { const hasAssistantLikeFinal = messages.some((message) => isAssistantLikeRole(message.role) && (!finalTraceId || message.traceId === finalTraceId)); const finalText = terminalProjection?.text ?? projectionText(snapshot.finalResponse); if (!hasAssistantLikeFinal && finalText) { - messages.push(messageFact({ role: "assistant", text: finalText, status: terminalProjection?.status ?? session.status, traceId: finalTraceId, source: terminalProjection?.source ?? "finalResponse" }, messages.length, session, snapshot)); + const finalMessageStatus = terminalProjection ? terminalProjection.status : sessionLifecycleProjectionStatus(session); + const finalMessageSource = terminalProjection ? terminalProjection.source : "finalResponse"; + messages.push(messageFact({ role: "assistant", text: finalText, status: finalMessageStatus, traceId: finalTraceId, source: finalMessageSource }, messages.length, session, snapshot)); } return messages; } -function terminalMessageProjection(session, snapshot, options, traceId) { - if (!traceId) return null; +function terminalMessageProjection(snapshot, options, currentTurn) { + if (!currentTurn) return null; + const traceId = currentTurn.traceId; const result = options.result ?? options.codeAgentChatResults?.get?.(traceId) ?? null; - const trace = options.trace ?? traceSnapshotSync(options, traceId); - const status = canonicalWorkbenchStatus( - result?.status ?? result?.agentRun?.terminalStatus ?? result?.agentRun?.commandState ?? result?.agentRun?.status ?? session?.status ?? snapshot.sessionStatus, - trace?.status - ); + const trace = currentTurn.trace; + const status = currentTurn.status; if (!TERMINAL_STATUSES.has(status) || RUNNING_STATUSES.has(status)) return null; const text = projectionText(result?.finalResponse, result?.assistantText, result?.reply, result?.text, result?.summary, trace?.finalResponse, trace?.terminalEvidence?.finalResponse, snapshot.finalResponse); - return { traceId, status, text, source: result ? "turn-result" : trace?.status && trace.status !== "missing" ? "trace-projection" : "session-snapshot" }; + return { traceId, status, text, source: "turn-projection" }; } function applyTerminalMessageProjection(message, projection) { @@ -656,7 +656,7 @@ async function visibleTraceContext(options, actor, traceId) { const resultVisible = result && (session || actor.role === "admin" || Boolean(result.ownerUserId)); if (!resultVisible && !session) return { visible: false }; const trace = await traceSnapshot(options, traceId); - const status = turnStatus(result, session, trace); + const status = workbenchTurnProjectionStatus(trace); return { visible: true, turnId: traceId, traceId, status, result, session, trace }; } @@ -689,24 +689,30 @@ function traceSnapshotLastSeq(snapshot) { return events.reduce((max, event, index) => Math.max(max, eventSeq(event, index)), 0); } -function turnStatus(result, session, trace) { - return canonicalWorkbenchStatus( - result?.status ?? - result?.agentRun?.terminalStatus ?? - result?.agentRun?.commandState ?? - result?.agentRun?.status ?? - session?.status ?? - objectValue(session?.session).sessionStatus, - trace?.status - ); +function currentWorkbenchTurnProjection(session, options = {}) { + const traceId = safeTraceId(session?.lastTraceId) ?? null; + if (!traceId) return null; + const trace = options.trace?.traceId === traceId ? options.trace : traceSnapshotSync(options, traceId); + const status = workbenchTurnProjectionStatus(trace); + return { + turnId: traceId, + traceId, + status, + trace, + eventCount: Number.isFinite(Number(trace?.eventCount)) ? Number(trace.eventCount) : null, + updatedAt: trace?.updatedAt ?? session?.updatedAt ?? null + }; } -function canonicalWorkbenchStatus(primary, traceStatus) { - const primaryStatus = normalizeStatus(primary); - const trace = normalizeStatus(traceStatus); - if (RUNNING_STATUSES.has(trace) && (primaryStatus === "idle" || primaryStatus === "unknown" || !TERMINAL_STATUSES.has(primaryStatus))) return trace; - if (TERMINAL_STATUSES.has(trace) && (!TERMINAL_STATUSES.has(primaryStatus) || RUNNING_STATUSES.has(primaryStatus))) return trace; - return primaryStatus || trace || "unknown"; +function workbenchTurnProjectionStatus(trace) { + const status = normalizeStatus(trace?.status); + if (status === "missing") return "unknown"; + if (RUNNING_STATUSES.has(status) || TERMINAL_STATUSES.has(status)) return status; + return "unknown"; +} + +function sessionLifecycleProjectionStatus(session) { + return normalizeStatus(session?.status); } function canActorReadSession(session, actor) { diff --git a/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts b/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts index fe5ca338..c613e924 100644 --- a/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-navigation.test.ts @@ -10,7 +10,7 @@ test("workbench navigation context recognizes workbench routes only while the co assert.equal(isWorkbenchNavigationContext({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbenchSession", routePath: "/workbench/sessions/ses_1" }), false); }); -test("workbench URL reflection never overrides a user navigation outside Workbench", () => { +test("workbench URL reflection never rewrites a user navigation outside Workbench", () => { assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: true, routeSection: "user", routeName: "Dashboard", routePath: "/dashboard", routeSessionId: null, sessionId: "ses_async" }), false); assert.equal(shouldReflectWorkbenchSessionUrl({ componentActive: false, routeSection: "workbench", routeName: "CodeWorkbench", routePath: "/workbench", routeSessionId: null, sessionId: "ses_async" }), false); }); diff --git a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts index c0fcef27..6ed5024e 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import type { WorkbenchSessionRecord } from "../src/types/index.ts"; import { initialWorkbenchSessionIdFromLocation } from "../src/stores/workbench-projection.ts"; import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectTraceAuthorityById } from "../src/stores/workbench-server-state.ts"; -import { stableSessionList } from "../src/stores/workbench-session.ts"; +import { sessionToSessionTab, stableSessionList } from "../src/stores/workbench-session.ts"; test("Workbench route parsing accepts only session routes", () => { assert.equal(initialWorkbenchSessionIdFromLocation({ pathname: "/workbench/sessions/ses_route" }), "ses_route"); @@ -28,7 +28,7 @@ test("Workbench server-state is keyed only by session and trace", () => { assert.equal(selectTraceAuthorityById(state).trc_route?.traceId, "trc_route"); }); -test("Workbench session list keeps selected detail but lets server summary override stale running status", () => { +test("Workbench session list accepts server projection while keeping selected messages", () => { const selected: WorkbenchSessionRecord = { sessionId: "ses_live", threadId: "thr_live", @@ -56,3 +56,22 @@ test("Workbench session list keeps selected detail but lets server summary overr assert.equal(next[0]?.updatedAt, "2026-06-18T00:00:03.000Z"); assert.equal(next[0]?.messages?.length, 2); }); + +test("Workbench session tab status is read from server projection", () => { + const session: WorkbenchSessionRecord = { + sessionId: "ses_tab_projection", + threadId: "thr_tab_projection", + status: "running", + lastTraceId: "trc_tab_projection", + messageCount: 2, + messages: [ + { id: "msg_tab_user", messageId: "msg_tab_user", role: "user", title: "用户", text: "ping", status: "sent", createdAt: "2026-06-18T00:00:00.000Z", sessionId: "ses_tab_projection", traceId: "trc_tab_projection" }, + { id: "msg_tab_agent", messageId: "msg_tab_agent", role: "agent", title: "Code Agent", text: "OK", status: "completed", createdAt: "2026-06-18T00:00:01.000Z", sessionId: "ses_tab_projection", traceId: "trc_tab_projection" } + ] + }; + + const tab = sessionToSessionTab(session, "ses_tab_projection"); + + assert.equal(tab.status, "running"); + assert.equal(tab.running, true); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 7b81eeaa..f2798e60 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -252,21 +252,8 @@ function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage | function resolveSessionTabStatus(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined): string { const authorityStatus = normalizeSessionStatus(authority?.status); - const terminalStatus = terminalStatusFromSession(session); - const sessionStatus = normalizeSessionStatus(session.status); - if (authorityStatus && isActiveStatus(authorityStatus) && terminalStatus) return terminalStatus; - if (sessionStatus && isActiveStatus(sessionStatus) && terminalStatus) return terminalStatus; - return authorityStatus ?? terminalStatus ?? sessionStatus ?? "unknown"; -} - -function terminalStatusFromSession(session: WorkbenchSessionRecord): string | null { - const agentMessage = latestAgentMessage(session.messages); - const candidates = [session.status, agentMessage?.status, agentMessage?.runnerTrace?.status]; - for (const candidate of candidates) { - const status = normalizeSessionStatus(candidate); - if (status && isTerminalStatus(status)) return status; - } - return null; + if (authorityStatus) return authorityStatus; + return normalizeSessionStatus(session.status) ?? "unknown"; } function messageBelongsToCancelTarget(message: ChatMessage, input: { targetSessionId?: string | null; targetThreadId?: string | null }): boolean { diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 55fcbc77..5e2fe67b 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -1015,9 +1015,9 @@ function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: return null; } -function realtimeSnapshotToTraceSnapshot(traceId: string, snapshot: WorkbenchRealtimeEvent["snapshot"], overrideEvents?: TraceEvent[]): TraceSnapshot { +function realtimeSnapshotToTraceSnapshot(traceId: string, snapshot: WorkbenchRealtimeEvent["snapshot"], eventPageEvents?: TraceEvent[]): TraceSnapshot { const source = snapshot ?? { traceId }; - const events = Array.isArray(overrideEvents) ? overrideEvents : Array.isArray(source.events) ? source.events : []; + const events = Array.isArray(eventPageEvents) ? eventPageEvents : Array.isArray(source.events) ? source.events : []; return { ...source, traceId: firstNonEmptyString(source.traceId, traceId) ?? traceId,