From bd77b52700b6534ba0cb3cf91ab10f761138eb10 Mon Sep 17 00:00:00 2001 From: UniDesk Codex Date: Thu, 2 Jul 2026 17:06:35 +0800 Subject: [PATCH] fix(web): share workbench terminal authority helpers --- .../src/stores/workbench-session.ts | 26 ++------ .../workbench-terminal-authority.test.ts | 23 +++++++ .../stores/workbench-terminal-authority.ts | 61 +++++++++++++++++++ web/hwlab-cloud-web/src/stores/workbench.ts | 5 +- 4 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 web/hwlab-cloud-web/src/stores/workbench-terminal-authority.test.ts create mode 100644 web/hwlab-cloud-web/src/stores/workbench-terminal-authority.ts diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 1b9f81c0..57dd6fe4 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -3,6 +3,7 @@ import type { ChatMessage, ProjectionDiagnostic, ProviderProfile, WorkbenchSessionRecord } from "@/types"; import { firstNonEmptyString } from "@/utils"; +import { messageHasSealedTerminalResult, messageIsSealedTerminal, turnStatusAuthorityIsSealed } from "./workbench-terminal-authority"; export interface ComposerState { disabled: boolean; @@ -82,10 +83,10 @@ export function resolveComposerState(input: { messages: ChatMessage[]; sessions? const turn = activeTraceId ? input.turnStatusAuthority?.[activeTraceId] : null; const activeByRequest = Boolean(input.chatPending && currentRequest && isActiveStatus(currentRequest.status ?? "running")); const activeByMessage = latestMessage?.role === "agent" && isActiveStatus(latestMessage.status); - const terminalByMessage = messageIsTerminalForSession(latestMessage); + const terminalByMessage = messageIsSealedTerminal(latestMessage); const turnStatus = normalizeSessionStatus(turn?.status); const terminalTurn = turn?.terminal === true || isTerminalStatus(turnStatus); - const terminalByTurn = terminalTurn && messageHasTerminalResponse(latestMessage); + const terminalByTurn = terminalTurn && messageHasSealedTerminalResult(latestMessage); const activeByStatus = !terminalByMessage && !terminalByTurn && (turn?.running === true || isActiveStatus(turnStatus) || activeByRequest || activeByMessage); const terminal = terminalByMessage || terminalByTurn; const active = activeSession(input.sessions ?? [], sessionId); @@ -106,7 +107,7 @@ export function resolveCancelableAgentMessage(input: { messages: ChatMessage[]; if (message.role !== "agent") continue; if (firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== targetTraceId) continue; if (!messageBelongsToCancelTarget(message, input)) continue; - if (messageIsTerminalForSession(message)) continue; + if (messageIsSealedTerminal(message)) continue; return message; } return null; @@ -312,27 +313,10 @@ function traceNeedsTurnStatusRefresh(traceId: string | null | undefined, message const turn = turnStatusAuthority?.[id]; const message = latestMessageForTrace(messages, id); if (turnStatusAuthorityIsSealed(turn, message)) return false; - if (messageIsTerminalForSession(message)) return false; + if (messageIsSealedTerminal(message)) return false; return true; } -function turnStatusAuthorityIsSealed(turn: TurnStatusAuthority | undefined, message: ChatMessage | null): boolean { - if (!(turn?.terminal === true || isTerminalStatus(turn?.status))) return false; - return message ? messageHasTerminalResponse(message) : false; -} - -function messageIsTerminalForSession(message: ChatMessage | null | undefined): boolean { - if (!message || message.role !== "agent") return false; - const status = normalizeSessionStatus(message.status); - return isTerminalStatus(status) && messageHasTerminalResponse(message); -} - -function messageHasTerminalResponse(message: ChatMessage | null | undefined): boolean { - if (!message || message.role !== "agent") return false; - const finalResponse = recordValue((message as Record).finalResponse); - return Boolean(firstNonEmptyString(message.text, message.content, finalResponse?.text, finalResponse?.content, finalResponse?.message)); -} - function resolveSessionTabStatus(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined): string { void session; const authorityStatus = normalizeSessionStatus(authority?.status); diff --git a/web/hwlab-cloud-web/src/stores/workbench-terminal-authority.test.ts b/web/hwlab-cloud-web/src/stores/workbench-terminal-authority.test.ts new file mode 100644 index 00000000..abc75d61 --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/workbench-terminal-authority.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { messageHasSealedTerminalResult, messageIsSealedTerminal, traceAuthorityIsSealed, turnStatusAuthorityIsSealed } from "./workbench-terminal-authority"; + +test("terminal authority requires terminal status and a final body for sealed messages", () => { + assert.equal(messageIsSealedTerminal({ role: "agent", status: "completed", text: "final answer" } as any), true); + assert.equal(messageIsSealedTerminal({ role: "agent", status: "completed" } as any), false); + assert.equal(messageIsSealedTerminal({ role: "agent", status: "running", text: "partial" } as any), false); +}); + +test("turn authority seals when terminal turn has final response even if message status is stale running", () => { + const turn = { traceId: "trc_terminal", status: "completed", terminal: true, running: false } as any; + const message = { role: "agent", status: "running", traceId: "trc_terminal", finalResponse: { text: "final answer" } } as any; + + assert.equal(turnStatusAuthorityIsSealed(turn, message), true); + assert.equal(traceAuthorityIsSealed(turn, message), true); +}); + +test("terminal result text can come from reply and finalText authority fields", () => { + assert.equal(messageHasSealedTerminalResult({ role: "agent", status: "running", reply: { finalText: "done" } } as any), true); + assert.equal(messageHasSealedTerminalResult({ role: "agent", status: "running", finalText: "done" } as any), true); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-terminal-authority.ts b/web/hwlab-cloud-web/src/stores/workbench-terminal-authority.ts new file mode 100644 index 00000000..22841227 --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/workbench-terminal-authority.ts @@ -0,0 +1,61 @@ +// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-18-p0-unique-projection. +// Responsibility: Shared terminal seal authority helpers for Workbench store reducers. + +import type { ChatMessage } from "@/types"; +import { firstNonEmptyString } from "@/utils"; +import { finalResponseText, messageText, normalizedStatusText } from "./workbench-message-projection-runtime"; +import type { TurnStatusAuthority } from "./workbench-session"; + +export function traceAuthorityIsSealed(turn: TurnStatusAuthority | undefined, message: ChatMessage | null | undefined): boolean { + return turnStatusAuthorityIsSealed(turn, message); +} + +export function turnStatusAuthorityIsSealed(turn: TurnStatusAuthority | undefined, message: ChatMessage | null | undefined): boolean { + if (!(turn?.terminal === true || isTerminalAuthorityStatus(turn?.status))) return false; + return messageHasSealedTerminalResult(message); +} + +export function messageIsSealedTerminal(message: ChatMessage | null | undefined): boolean { + if (!message || message.role !== "agent") return false; + return isTerminalAuthorityStatus(message.status) && messageHasSealedTerminalResult(message); +} + +export function messageHasSealedTerminalResult(message: ChatMessage | null | undefined): boolean { + if (!message || message.role !== "agent") return false; + const record = message as Record; + const reply = recordValue(record.reply); + return Boolean(firstNonEmptyString( + messageText(message.text), + messageText(record.content), + finalResponseText(record.finalResponse), + finalResponseText(record.reply), + messageText(reply?.finalText), + messageText(record.finalText), + messageText(record.final) + )); +} + +function recordValue(value: unknown): Record | null { + return value && typeof value === "object" ? value as Record : null; +} + +function isTerminalAuthorityStatus(value: unknown): boolean { + return [ + "completed", + "failed", + "blocked", + "timeout", + "canceled", + "stale", + "thread-resume-failed", + "interrupted", + "expired" + ].includes(normalizeTerminalAuthorityStatus(value) ?? ""); +} + +function normalizeTerminalAuthorityStatus(value: unknown): string | null { + const status = normalizedStatusText(value); + if (status === "cancelled") return "canceled"; + if (status === "error") return "failed"; + return status; +} diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index e8faaa3d..224676a8 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -20,6 +20,7 @@ import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection"; import { cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state"; import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache"; import { reduceWorkbenchRealtimeEvent, type WorkbenchRealtimeAction } from "./workbench-event-reducer"; +import { messageHasSealedTerminalResult, messageIsSealedTerminal, traceAuthorityIsSealed } from "./workbench-terminal-authority"; import { agentErrorDisplayText, agentErrorFromApiFailure, @@ -1438,10 +1439,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { })); } - function messageHasSealedTerminalResult(message: ChatMessage | null): boolean { - return messageHasTerminalResponse(message); - } - function failTrace(traceId: string, message: string): void { markMessage(traceId, { status: "failed", text: message }); chatPending.value = false;