diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.test.ts b/web/hwlab-cloud-web/src/stores/workbench-session.test.ts index 222afc01..43830783 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.test.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; -import type { ChatMessage } from "../types"; -import { resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds } from "./workbench-session"; +import type { ChatMessage, WorkbenchSessionRecord } from "../types"; +import { resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, sessionToSessionTab, shouldHydrateTerminalTraceFromRest } from "./workbench-session"; import type { TurnStatusAuthority } from "./workbench-session"; function agentMessage(input: Partial & Pick): ChatMessage { @@ -99,3 +99,27 @@ test("turn status refresh selector skips sealed terminal traces", () => { assert.deepEqual(selectActiveTurnStatusRefreshTraceIds({ messages: [sealed], currentRequestTraceId: sealed.traceId, turnStatusAuthority: { [sealed.traceId]: turnStatus({ traceId: sealed.traceId, sessionId, status: "completed", running: false, terminal: true }) }, limit: 1 }), []); }); + +test("terminal REST hydrate skips sealed terminal trace authority", () => { + const sessionId = "ses_terminal_hydrate_sealed"; + const traceId = "trc_terminal_hydrate_sealed"; + const sealed = agentMessage({ id: "msg_terminal_hydrate_sealed", traceId, sessionId, status: "completed", text: "final response" }); + const unsealed = agentMessage({ id: "msg_terminal_hydrate_unsealed", traceId, sessionId, status: "completed" }); + const turnStatusAuthority = { [traceId]: turnStatus({ traceId, sessionId, status: "completed", running: false, terminal: true }) }; + + assert.equal(shouldHydrateTerminalTraceFromRest({ traceId, messages: [sealed], turnStatusAuthority }), false); + assert.equal(shouldHydrateTerminalTraceFromRest({ traceId, messages: [unsealed], turnStatusAuthority }), true); + assert.equal(shouldHydrateTerminalTraceFromRest({ traceId, messages: [], turnStatusAuthority }), true); +}); + +test("session rail status prefers newer running trace over stale completed authority", () => { + const sessionId = "ses_session_rail_stale"; + const older = agentMessage({ id: "msg_session_rail_old", traceId: "trc_session_rail_old", sessionId, status: "completed", text: "previous final" }); + const current = agentMessage({ id: "msg_session_rail_current", traceId: "trc_session_rail_current", sessionId, status: "running" }); + const session: WorkbenchSessionRecord = { sessionId, status: "completed", lastTraceId: older.traceId, messageCount: 2, messages: [older, current] }; + const tab = sessionToSessionTab(session, sessionId, { [sessionId]: { sessionId, status: "completed", lastTraceId: older.traceId } }); + + assert.equal(tab.status, "running"); + assert.equal(tab.running, true); + assert.equal(tab.lastTraceId, current.traceId); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 0fb93c84..cd389f79 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -129,6 +129,15 @@ export function selectActiveTurnStatusRefreshTraceIds(input: { messages: ChatMes return [...traceIds]; } +export function shouldHydrateTerminalTraceFromRest(input: { traceId?: string | null; messages: ChatMessage[]; turnStatusAuthority?: TurnStatusAuthorityMap }): boolean { + const traceId = firstNonEmptyString(input.traceId); + if (!traceId) return false; + const message = latestMessageForTrace(input.messages, traceId); + if (turnStatusAuthorityIsSealed(input.turnStatusAuthority?.[traceId], message)) return false; + if (messageIsSealedTerminal(message)) return false; + return true; +} + export function shouldShowSessionListLoading(input: { loading: boolean; sessionsReady: boolean }): boolean { return input.loading && !input.sessionsReady; } @@ -136,10 +145,11 @@ export function shouldShowSessionListLoading(input: { loading: boolean; sessions export function sessionToSessionTab(session: WorkbenchSessionRecord, activeSessionId: string | null, sessionStatusAuthority: SessionStatusAuthorityMap = {}): SessionTab { const sessionId = session.sessionId; const updatedAt = sessionDisplayUpdatedAt(session); - const status = resolveSessionTabStatus(session, sessionStatusAuthority[sessionId]); + const latestAgent = latestAgentMessage(session.messages); + const status = resolveSessionTabStatus(session, sessionStatusAuthority[sessionId], latestAgent); const projection = projectionFromSession(session, sessionStatusAuthority[sessionId]); const projectionText = projectionDiagnosticText(projection); - const trace = firstNonEmptyString(latestAgentMessage(session.messages)?.traceId, session.messages?.at(-1)?.traceId, session.lastTraceId); + const trace = firstNonEmptyString(latestAgent?.traceId, latestAgent?.runnerTrace?.traceId, session.messages?.at(-1)?.traceId, session.lastTraceId); const userMessage = session.messages?.find((message) => message.role === "user"); const running = isRunningIndicatorStatus(status); const preview = firstReadableSentence( @@ -318,10 +328,13 @@ function traceNeedsTurnStatusRefresh(traceId: string | null | undefined, message return true; } -function resolveSessionTabStatus(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined): string { - void session; +function resolveSessionTabStatus(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined, latestAgent: ChatMessage | null = latestAgentMessage(session.messages)): string { + const latestTraceId = firstNonEmptyString(latestAgent?.traceId, latestAgent?.runnerTrace?.traceId); + const authorityTraceId = firstNonEmptyString(authority?.lastTraceId); + const latestStatus = normalizeSessionStatus(latestAgent?.status); + if (latestTraceId && authorityTraceId && latestTraceId !== authorityTraceId && latestStatus && isActiveStatus(latestStatus)) return latestStatus; const authorityStatus = normalizeSessionStatus(authority?.status); - return authorityStatus ?? "unknown"; + return authorityStatus ?? latestStatus ?? normalizeSessionStatus(session.status) ?? "unknown"; } function projectionFromSession(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined): ProjectionDiagnostic | null { diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 02ef25d4..8a23abdd 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -16,7 +16,7 @@ import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, Ap import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils"; import { composeWorkbenchScopedKey } from "@/utils/workbench-key"; import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, recordWorkbenchRuntimeDiagnostic, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance"; -import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, selectActiveTurnStatusRefreshTraceIds, shouldHydrateTerminalTraceFromRest, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session"; import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection"; import { cleanupWorkbenchServerStateSessions, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state"; import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache"; @@ -476,7 +476,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!id) return false; const ownerSessionId = normalizeWorkbenchSessionId(sessionId) ?? traceOwnerSessionId(id, null); const source = ownerSessionId ? serverState.value.messagesBySessionId[ownerSessionId] ?? [] : messages.value; - return traceProjectionIsTerminalSealed(id, source); + return !shouldHydrateTerminalTraceFromRest({ traceId: id, messages: source, turnStatusAuthority: turnStatusAuthority.value }); } function appendActiveMessages(...items: ChatMessage[]): void { @@ -1140,6 +1140,12 @@ export const useWorkbenchStore = defineStore("workbench", () => { return; } const ownerBefore = traceOwnerSessionId(id, null); + const ownerMessagesBeforeRest = ownerBefore ? serverState.value.messagesBySessionId[ownerBefore] ?? [] : messages.value; + if (!shouldHydrateTerminalTraceFromRest({ traceId: id, messages: ownerMessagesBeforeRest, turnStatusAuthority: turnStatusAuthority.value })) { + clearActiveTraceRestGapFill(id); + recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId: ownerBefore, traceId: id, outcome: "ok", diagnostic: { code: "terminal_low_priority_rest_gap_skip", reason, source: "active-rest-gap-preflight", valuesRedacted: true } }); + return; + } if (ownerBefore === activeSessionId.value) recordActivity(reason); await refreshTurnStatusByTraceId(id); const ownerSessionId = traceOwnerSessionId(id, turnStatusAuthority.value[id]?.sessionId ?? null) ?? ownerBefore; @@ -1376,6 +1382,12 @@ export const useWorkbenchStore = defineStore("workbench", () => { return; } const ownerBefore = traceOwnerSessionId(id, null); + const ownerMessagesBeforeRest = ownerBefore ? serverState.value.messagesBySessionId[ownerBefore] ?? [] : messages.value; + if (!shouldHydrateTerminalTraceFromRest({ traceId: id, messages: ownerMessagesBeforeRest, turnStatusAuthority: turnStatusAuthority.value })) { + clearActiveTraceRestGapFill(id); + recordWorkbenchRuntimeDiagnostic({ module: "workbench-terminal-priority", sessionId: ownerBefore, traceId: id, outcome: "ok", diagnostic: { code: "terminal_low_priority_terminal_rest_skip", reason, source: "terminal-rest-preflight", valuesRedacted: true } }); + return; + } if (ownerBefore === activeSessionId.value) recordActivity(reason); await refreshTurnStatusByTraceId(id); const ownerSessionId = traceOwnerSessionId(id, turnStatusAuthority.value[id]?.sessionId ?? null) ?? ownerBefore;