From bad37430ce99240886e777089a31acf5423912d3 Mon Sep 17 00:00:00 2001 From: UniDesk Codex Date: Thu, 2 Jul 2026 11:38:45 +0800 Subject: [PATCH] fix: keep workbench session status upstream --- .../scripts/workbench-server-state.test.ts | 17 ++- .../src/stores/workbench-server-state.test.ts | 114 ++++++++++++++---- .../src/stores/workbench-server-state.ts | 88 ++++++++------ web/hwlab-cloud-web/src/stores/workbench.ts | 6 - 4 files changed, 158 insertions(+), 67 deletions(-) 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 eaa418a3..115cd9de 100644 --- a/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-server-state.test.ts @@ -104,7 +104,7 @@ test("Workbench server-state session detail updates session status authority", ( assert.equal(authority?.lastTraceId, "trc_backfill"); }); -test("Workbench session status action preserves sealed completed authority for the same trace", () => { +test("Workbench turn status action seals completed authority for the same trace", () => { let state = createWorkbenchServerState(); state = reduceWorkbenchServerState(state, { type: "session.messages", @@ -115,8 +115,8 @@ test("Workbench session status action preserves sealed completed authority for t ] }); state = reduceWorkbenchServerState(state, { - type: "session.status", - session: { sessionId: "ses_status_seal", status: "running", lastTraceId: "trc_status_seal" } + type: "turn.status", + turn: { traceId: "trc_status_seal", sessionId: "ses_status_seal", status: "completed", running: false, terminal: true } }); let authority = selectSessionStatusAuthority(state).ses_status_seal; @@ -124,8 +124,8 @@ test("Workbench session status action preserves sealed completed authority for t assert.equal(authority?.lastTraceId, "trc_status_seal"); state = reduceWorkbenchServerState(state, { - type: "session.status", - session: { sessionId: "ses_status_seal", status: "running", lastTraceId: "trc_status_next" } + type: "turn.status", + turn: { traceId: "trc_status_next", sessionId: "ses_status_seal", status: "running", running: true, terminal: false } }); authority = selectSessionStatusAuthority(state).ses_status_seal; @@ -252,6 +252,13 @@ test("Workbench sealed completed final body survives stale running session messa assert.equal(agent?.status, "completed"); assert.equal(agent?.text, "final answer"); assert.equal((agent?.finalResponse as { text?: string } | undefined)?.text, "final answer"); + assert.equal(selectSessionStatusAuthority(state).ses_final_seal?.status, "running"); + + state = reduceWorkbenchServerState(state, { + type: "turn.status", + turn: { traceId: "trc_final_seal", sessionId: "ses_final_seal", status: "completed", running: false, terminal: true, updatedAt: "2026-06-24T00:00:06.000Z" } + }); + assert.equal(selectSessionStatusAuthority(state).ses_final_seal?.status, "completed"); state = reduceWorkbenchServerState(state, { diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts index 0f8c3e53..c2e27a05 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.test.ts @@ -1,15 +1,42 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; +import type { ChatMessage, WorkbenchSessionRecord } from "../types"; import { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectSessionStatusAuthority } from "./workbench-server-state"; +function sessionRecord(input: WorkbenchSessionRecord): WorkbenchSessionRecord { + return input; +} + +function agentMessage(input: Partial & Pick): ChatMessage { + return { + role: "agent", + title: "Code Agent", + text: "", + status: "running", + createdAt: "2026-07-01T00:00:00.000Z", + ...input + }; +} + +function userMessage(input: Partial & Pick): ChatMessage { + return { + role: "user", + title: "用户", + text: "", + status: "sent", + createdAt: "2026-07-01T00:00:00.000Z", + ...input + }; +} + test("server state reducer keeps completed message without final response unsealed", () => { const sessionId = "ses_state_completed_without_final"; const traceId = "trc_state_completed_without_final"; let state = createWorkbenchServerState(); - state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", messages: [] } as any }); - state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_completed_without_final", role: "agent", status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z" } as any] }); - state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_completed_without_final", role: "agent", status: "completed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 } as any] }); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) }); + state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_completed_without_final", status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z" })] }); + state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_completed_without_final", status: "completed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 })] }); const [message] = selectActiveMessages(state, sessionId); assert.equal(message.status, "running"); @@ -22,9 +49,9 @@ test("server state reducer keeps failed message without terminal body unsealed", const sessionId = "ses_state_failed_without_body"; const traceId = "trc_state_failed_without_body"; let state = createWorkbenchServerState(); - state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", messages: [] } as any }); - state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_failed_without_body", role: "agent", status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z" } as any] }); - state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_failed_without_body", role: "agent", status: "failed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 } as any] }); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) }); + state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_failed_without_body", status: "running", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z" })] }); + state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_failed_without_body", status: "failed", traceId, sessionId, startedAt: "2026-07-01T00:00:00.000Z", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 })] }); const [message] = selectActiveMessages(state, sessionId); assert.equal(message.status, "running"); @@ -37,8 +64,10 @@ test("server state reducer seals completed message when final response exists", const sessionId = "ses_state_completed_with_final"; const traceId = "trc_state_completed_with_final"; let state = createWorkbenchServerState(); - state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", messages: [] } as any }); - state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [{ id: "msg_state_completed_with_final", role: "agent", status: "completed", traceId, sessionId, text: "final answer", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 } as any] }); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) }); + state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [agentMessage({ id: "msg_state_completed_with_final", status: "completed", traceId, sessionId, text: "final answer", finishedAt: "2026-07-01T00:00:02.000Z", durationMs: 2000 })] }); + assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "running"); + state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId, status: "completed", running: false, terminal: true, sessionId, updatedAt: "2026-07-01T00:00:02.000Z" } }); const [message] = selectActiveMessages(state, sessionId); assert.equal(message.status, "completed"); @@ -50,24 +79,26 @@ test("server state reducer keeps sealed terminal messages when a stale partial s const sessionId = "ses_state_partial_snapshot"; const traceId = "trc_state_partial_snapshot"; let state = createWorkbenchServerState(); - state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", messages: [] } as any }); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", messages: [] }) }); state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [ - { id: "msg_state_partial_user_1", role: "user", status: "sent", text: "one", sessionId, traceId: "trc_state_partial_1" } as any, - { id: "msg_state_partial_agent_1", role: "agent", status: "completed", text: "one done", sessionId, traceId: "trc_state_partial_1" } as any, - { id: "msg_state_partial_user_2", role: "user", status: "sent", text: "two", sessionId, traceId } as any, - { id: "msg_state_partial_agent_2", role: "agent", status: "completed", text: "final answer", finalResponse: { text: "final answer" }, sessionId, traceId, finishedAt: "2026-07-01T00:00:04.000Z", durationMs: 4000 } as any + userMessage({ id: "msg_state_partial_user_1", text: "one", sessionId, traceId: "trc_state_partial_1" }), + agentMessage({ id: "msg_state_partial_agent_1", status: "completed", text: "one done", sessionId, traceId: "trc_state_partial_1" }), + userMessage({ id: "msg_state_partial_user_2", text: "two", sessionId, traceId }), + agentMessage({ id: "msg_state_partial_agent_2", status: "completed", text: "final answer", finalResponse: { text: "final answer" }, sessionId, traceId, finishedAt: "2026-07-01T00:00:04.000Z", durationMs: 4000 }) ] }); state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [ - { id: "msg_state_partial_user_1", role: "user", status: "sent", text: "one", sessionId, traceId: "trc_state_partial_1" } as any, - { id: "msg_state_partial_agent_1", role: "agent", status: "completed", text: "one done", sessionId, traceId: "trc_state_partial_1" } as any + userMessage({ id: "msg_state_partial_user_1", text: "one", sessionId, traceId: "trc_state_partial_1" }), + agentMessage({ id: "msg_state_partial_agent_1", status: "completed", text: "one done", sessionId, traceId: "trc_state_partial_1" }) ] }); const messages = selectActiveMessages(state, sessionId); assert.equal(messages.length, 4); - const terminal = messages.find((message) => message.id === "msg_state_partial_agent_2") as any; + const terminal = messages.find((message) => message.id === "msg_state_partial_agent_2"); assert.equal(terminal.status, "completed"); assert.equal(terminal.text, "final answer"); - assert.equal(terminal.finalResponse.text, "final answer"); + assert.equal((terminal.finalResponse as { text?: string } | undefined)?.text, "final answer"); + assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "running"); + state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId, status: "completed", running: false, terminal: true, sessionId, updatedAt: "2026-07-01T00:00:04.000Z" } }); assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "completed"); }); @@ -75,15 +106,56 @@ test("server state reducer promotes failed terminal message authority and ignore const sessionId = "ses_state_failed_terminal_authority"; const traceId = "trc_state_failed_terminal_authority"; let state = createWorkbenchServerState(); - state = reduceWorkbenchServerState(state, { type: "session.detail", session: { sessionId, status: "running", lastTraceId: traceId, messages: [] } as any }); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "running", lastTraceId: traceId, messages: [] }) }); state = reduceWorkbenchServerState(state, { type: "session.messages", sessionId, messages: [ - { id: "msg_state_failed_user", role: "user", status: "sent", text: "one", sessionId, traceId } as any, - { id: "msg_state_failed_agent", role: "agent", status: "failed", text: "Workbench terminal failed: provider-stream-disconnected", finalResponse: { text: "Workbench terminal failed: provider-stream-disconnected" }, sessionId, traceId, finishedAt: "2026-07-01T00:00:05.000Z", durationMs: 5000 } as any + userMessage({ id: "msg_state_failed_user", text: "one", sessionId, traceId }), + agentMessage({ id: "msg_state_failed_agent", status: "failed", text: "Workbench terminal failed: provider-stream-disconnected", finalResponse: { text: "Workbench terminal failed: provider-stream-disconnected" }, sessionId, traceId, finishedAt: "2026-07-01T00:00:05.000Z", durationMs: 5000 }) ] }); + assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "running"); + state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId, status: "failed", running: false, terminal: true, sessionId, updatedAt: "2026-07-01T00:00:05.000Z" } }); assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "failed"); - state = reduceWorkbenchServerState(state, { type: "session.list", sessions: [{ sessionId, status: "running", lastTraceId: traceId, messages: [] } as any] }); + state = reduceWorkbenchServerState(state, { type: "session.list", sessions: [sessionRecord({ sessionId, status: "running", lastTraceId: traceId, messages: [] })] }); assert.equal(selectSessionStatusAuthority(state)[sessionId]?.status, "failed"); assert.equal(selectActiveMessages(state, sessionId).find((message) => message.id === "msg_state_failed_agent")?.status, "failed"); }); + +test("server state reducer keeps newer running turn authority over older terminal message", () => { + const sessionId = "ses_state_new_turn_running"; + const oldTraceId = "trc_state_old_completed"; + const newTraceId = "trc_state_new_running"; + let state = createWorkbenchServerState(); + state = reduceWorkbenchServerState(state, { + type: "session.detail", + session: { + sessionId, + status: "completed", + lastTraceId: oldTraceId, + messages: [ + userMessage({ id: "msg_state_old_user", text: "one", sessionId, traceId: oldTraceId }), + agentMessage({ id: "msg_state_old_agent", status: "completed", text: "one done", finalResponse: { text: "one done" }, sessionId, traceId: oldTraceId }), + userMessage({ id: "msg_state_new_user", text: "two", sessionId, traceId: newTraceId }), + agentMessage({ id: "msg_state_new_agent", status: "running", text: "", sessionId, traceId: newTraceId }) + ] + } + }); + state = reduceWorkbenchServerState(state, { type: "turn.status", turn: { traceId: newTraceId, status: "running", running: true, terminal: false, sessionId, updatedAt: "2026-07-02T00:00:04.000Z" } }); + state = reduceWorkbenchServerState(state, { type: "session.status", session: { sessionId, status: "running", lastTraceId: newTraceId, updatedAt: "2026-07-02T00:00:04.000Z" } }); + state = reduceWorkbenchServerState(state, { type: "session.list", sessions: [sessionRecord({ sessionId, status: "completed", lastTraceId: oldTraceId, messages: [] })] }); + state = reduceWorkbenchServerState(state, { type: "session.detail", session: sessionRecord({ sessionId, status: "completed", lastTraceId: oldTraceId, messages: [] }) }); + state = reduceWorkbenchServerState(state, { + type: "session.messages", + sessionId, + messages: [ + userMessage({ id: "msg_state_old_user", text: "one", sessionId, traceId: oldTraceId }), + agentMessage({ id: "msg_state_old_agent", status: "completed", text: "one done", finalResponse: { text: "one done" }, sessionId, traceId: oldTraceId }), + userMessage({ id: "msg_state_new_user", text: "two", sessionId, traceId: newTraceId }), + agentMessage({ id: "msg_state_new_agent", status: "running", text: "", sessionId, traceId: newTraceId }) + ] + }); + + const authority = selectSessionStatusAuthority(state)[sessionId]; + assert.equal(authority?.status, "running"); + assert.equal(authority?.lastTraceId, newTraceId); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index 7231b3eb..57cf46a8 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -53,7 +53,7 @@ export function reduceWorkbenchServerState(state: WorkbenchServerState, action: case "session.cleanupDropped": return cleanupWorkbenchServerStateSessions(state, droppedSessionIds(state, new Set(action.keepSessionIds))); case "turn.status": - return { ...state, turnStatusByTraceId: { ...state.turnStatusByTraceId, [action.turn.traceId]: mergeTurnStatusAuthority(state.turnStatusByTraceId[action.turn.traceId], action.turn) } }; + return reduceTurnStatus(state, action.turn); case "turn.forget": return { ...state, turnStatusByTraceId: withoutKey(state.turnStatusByTraceId, action.traceId) }; case "trace.snapshot": @@ -93,11 +93,12 @@ export function selectTraceAuthorityById(state: WorkbenchServerState): Readonly< } export function selectSessionList(state: WorkbenchServerState): WorkbenchSessionRecord[] { - return state.sessionOrder.map((sessionId) => state.sessionsById[sessionId]).filter((session): session is WorkbenchSessionRecord => Boolean(session)); + return state.sessionOrder.map((sessionId) => projectSessionRecord(state.sessionsById[sessionId], state.sessionStatusById[sessionId])).filter((session): session is WorkbenchSessionRecord => Boolean(session)); } export function selectActiveSession(state: WorkbenchServerState, sessionId: string | null): WorkbenchSessionRecord | null { - return sessionId ? state.sessionsById[sessionId] ?? null : null; + if (!sessionId) return null; + return projectSessionRecord(state.sessionsById[sessionId], state.sessionStatusById[sessionId]); } export function selectActiveMessages(state: WorkbenchServerState, sessionId: string | null): ChatMessage[] { @@ -110,9 +111,8 @@ function reduceSessionDetail(state: WorkbenchServerState, session: WorkbenchSess const existing = state.sessionsById[sessionId]; const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages ?? []; const messages = Array.isArray(session.messages) ? mergeMessageList(existingMessages, session.messages) : existingMessages; - const messageStatus = sessionStatusAuthorityFromMessages(sessionId, messages); - const merged = mergeSessionRecord(existing, { ...session, status: messageStatus?.status ?? session.status, messages }); - const sessionStatus = messageStatus ?? sessionStatusAuthorityFromDetail(session); + const merged = mergeSessionRecord(existing, { ...session, messages }); + const sessionStatus = sessionStatusAuthorityFromDetail(session); return { ...state, sessionOrder: state.sessionOrder.includes(sessionId) ? state.sessionOrder : [sessionId, ...state.sessionOrder], @@ -139,9 +139,8 @@ function reduceSessionListItem(state: WorkbenchServerState, session: WorkbenchSe const existing = state.sessionsById[sessionId]; const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages; const messages = Array.isArray(existingMessages) ? existingMessages : Array.isArray(session.messages) ? session.messages : []; - const messageStatus = sessionStatusAuthorityFromMessages(sessionId, messages); - const merged = mergeSessionRecord(existing, { ...session, status: messageStatus?.status ?? session.status, messages }); - const sessionStatus = messageStatus ?? sessionStatusAuthorityFromDetail(session); + const merged = mergeSessionRecord(existing, { ...session, messages }); + const sessionStatus = sessionStatusAuthorityFromDetail(session); return { ...state, sessionsById: { ...state.sessionsById, [sessionId]: merged }, @@ -156,18 +155,27 @@ function reduceSessionMessages(state: WorkbenchServerState, sessionId: string | const existing = state.sessionsById[sessionId]; const existingMessages = state.messagesBySessionId[sessionId] ?? existing?.messages ?? []; const mergedMessages = mergeMessageList(existingMessages, messages); - const reconciledStatus = sessionStatusAuthorityFromMessages(sessionId, mergedMessages); - const session = existing ? { ...existing, status: reconciledStatus?.status ?? existing.status, messages: mergedMessages, messageCount: mergedMessages.length } : null; + const session = existing ? { ...existing, messages: mergedMessages, messageCount: mergedMessages.length } : null; return { ...state, sessionsById: session ? { ...state.sessionsById, [sessionId]: session } : state.sessionsById, - sessionStatusById: reconciledStatus - ? { ...state.sessionStatusById, [sessionId]: mergeSessionStatusAuthority(state.sessionStatusById[sessionId], reconciledStatus) } - : state.sessionStatusById, messagesBySessionId: { ...state.messagesBySessionId, [sessionId]: mergedMessages } }; } +function reduceTurnStatus(state: WorkbenchServerState, turn: TurnStatusAuthority): WorkbenchServerState { + const nextTurn = mergeTurnStatusAuthority(state.turnStatusByTraceId[turn.traceId], turn); + const nextTurns = { ...state.turnStatusByTraceId, [turn.traceId]: nextTurn }; + const sessionStatus = sessionStatusAuthorityFromTurn(nextTurn); + return { + ...state, + turnStatusByTraceId: nextTurns, + sessionStatusById: sessionStatus + ? { ...state.sessionStatusById, [sessionStatus.sessionId]: mergeSessionStatusAuthority(state.sessionStatusById[sessionStatus.sessionId], sessionStatus) } + : state.sessionStatusById + }; +} + function reduceMessageSnapshot(state: WorkbenchServerState, sessionId: string | null | undefined, message: ChatMessage | null | undefined): WorkbenchServerState { const id = sessionId || message?.sessionId; if (!id || !message) return state; @@ -386,6 +394,21 @@ function mergeSessionRecord(existing: WorkbenchSessionRecord | undefined, incomi }; } +function projectSessionRecord(session: WorkbenchSessionRecord | null | undefined, authority: SessionStatusAuthority | null | undefined): WorkbenchSessionRecord | null { + if (!session) return null; + if (!authority?.status || normalizedMessageStatus(session.status) === "archived") return session; + const projection = authority.projection ?? session.projection; + return { + ...session, + status: authority.status, + updatedAt: textValue(authority.updatedAt) ?? session.updatedAt, + lastTraceId: textValue(authority.lastTraceId) ?? session.lastTraceId, + projection, + projectionHealth: authority.projection?.projectionHealth ?? session.projectionHealth, + projectionStatus: authority.projection?.projectionStatus ?? session.projectionStatus + }; +} + function sessionStatusAuthorityFromDetail(session: WorkbenchSessionRecord): SessionStatusAuthority | null { const sessionId = session.sessionId; if (!sessionId || typeof session.status !== "string") return null; @@ -399,22 +422,24 @@ function sessionStatusAuthorityFromDetail(session: WorkbenchSessionRecord): Sess }; } -function sessionStatusAuthorityFromMessages(sessionId: string, messages: ChatMessage[]): SessionStatusAuthority | null { - const message = [...messages].reverse().find((item) => messageHasTerminalResponse(item)); - if (!message) return null; - const status = canonicalTerminalMessageStatus(message.status); +function sessionStatusAuthorityFromTurn(turn: TurnStatusAuthority): SessionStatusAuthority | null { + const sessionId = textValue(turn.sessionId); + if (!sessionId) return null; + const status = textValue(turn.status) ?? (turn.running === true ? "running" : null); + if (!status) return null; return { sessionId, status, - updatedAt: textValue(message.updatedAt) ?? textValue(message.finishedAt) ?? textValue(message.lastEventAt), - lastTraceId: textValue(message.traceId) ?? textValue(message.runnerTrace?.traceId), - projection: projectionFromMessageRecord(message), - loadedAt: new Date().toISOString() + updatedAt: textValue(turn.updatedAt), + lastTraceId: textValue(turn.traceId), + projection: turn.projection ?? null, + loadedAt: turn.loadedAt ?? new Date().toISOString() }; } function mergeSessionStatusAuthority(existing: SessionStatusAuthority | undefined, incoming: SessionStatusAuthority): SessionStatusAuthority { if (isTerminalSessionStatusAuthority(existing) && !isTerminalSessionStatusAuthority(incoming) && isSameTraceAuthority(existing, incoming) && isRunningSessionStatus(incoming.status)) return existing; + if (isRunningSessionStatus(existing?.status) && isTerminalSessionStatusAuthority(incoming) && isDifferentTraceAuthority(existing, incoming)) return existing; return { ...(existing ?? {}), ...incoming, @@ -452,6 +477,12 @@ function isSameTraceAuthority(left: SessionStatusAuthority, right: SessionStatus return Boolean(leftTrace && rightTrace && leftTrace === rightTrace); } +function isDifferentTraceAuthority(left: SessionStatusAuthority | undefined, right: SessionStatusAuthority | undefined): boolean { + const leftTrace = textValue(left?.lastTraceId); + const rightTrace = textValue(right?.lastTraceId); + return Boolean(leftTrace && rightTrace && leftTrace !== rightTrace); +} + function isRunningSessionStatus(value: unknown): boolean { return ["", "pending", "running", "accepted", "queued", "dispatching", "streaming", "processing", "retrying", "busy", "creating"].includes(normalizedMessageStatus(value)); } @@ -463,23 +494,10 @@ function messageHasTerminalResponse(message: ChatMessage): boolean { return Boolean(messageFinalResponseText(message)); } -function canonicalTerminalMessageStatus(value: unknown): string { - const status = normalizedMessageStatus(value); - return status === "cancelled" ? "canceled" : status; -} - function messageFinalResponseText(message: ChatMessage): string | null { return textValue(message.text) ?? textValue(message.content) ?? nestedTextValue((message as Record).finalResponse); } -function projectionFromMessageRecord(message: ChatMessage): ProjectionDiagnostic | null { - const direct = message.projection && typeof message.projection === "object" ? message.projection : null; - if (direct) return direct; - const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace as Record : null; - const traceProjection = trace?.projection && typeof trace.projection === "object" ? trace.projection as ProjectionDiagnostic : null; - return traceProjection ?? null; -} - function nestedTextValue(value: unknown): string | null { const direct = textValue(value); if (direct) return direct; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index a6912469..f7ba813f 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -1462,7 +1462,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { const running = existing?.running === true || (!isTerminalMessageStatus(message?.status) && isTraceActiveStatus(message?.status)); const now = new Date().toISOString(); reduceServerState({ type: "turn.status", turn: { traceId, status, running, terminal: false, sessionId, threadId: firstNonEmptyString(message?.threadId, message?.runnerTrace?.threadId) ?? existing?.threadId ?? null, updatedAt: now, projection, loadedAt: now } }); - if (sessionId) reduceServerState({ type: "session.status", session: { sessionId, status: activeSession.value?.status ?? status, updatedAt: now, lastTraceId: traceId, projection, loadedAt: now } }); const projectionError = agentErrorFromProjection(projection); updateActiveMessages((source) => source.map((item) => { if (!shouldApplyTraceToMessage(item, traceId, sessionId)) return item; @@ -1556,10 +1555,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { type: "turn.status", turn: { traceId: input.traceId, status: "failed", running: false, terminal: true, sessionId: input.sessionId, threadId: input.threadId, updatedAt: finishedAt, projection: input.projection, loadedAt: finishedAt } }); - reduceServerState({ - type: "session.status", - session: { sessionId: input.sessionId, status: "failed", updatedAt: finishedAt, lastTraceId: input.traceId, projection: input.projection, loadedAt: finishedAt } - }); const existing = sessions.value.find((session) => session.sessionId === input.sessionId) ?? null; const projectedMessages = serverState.value.messagesBySessionId[input.sessionId] ?? messages.value.filter((message) => firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId) === input.sessionId); rememberSessionList(mergeSessionIntoList(sessions.value, { @@ -1579,7 +1574,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { 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; rememberSessionList(mergeSessionIntoList(sessions.value, { ...(existing ?? {}),