From 1fdd50b7bb7e2b169fc45cf738aa42ebeed224f4 Mon Sep 17 00:00:00 2001 From: UniDesk Codex Date: Wed, 1 Jul 2026 01:16:45 +0800 Subject: [PATCH] fix: seal restored workbench terminal turns --- ...rkbench-message-projection-runtime.test.ts | 38 ++++++++++++++++++- .../workbench-message-projection-runtime.ts | 28 ++++++++++++++ web/hwlab-cloud-web/src/stores/workbench.ts | 31 ++++++++++++++- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts index 6c300c3f..d4089409 100644 --- a/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-message-projection-runtime.test.ts @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { AgentChatResultResponse, ChatMessage } from "../src/types/index.ts"; -import { clearRunnerTraceTransientDiagnostics, mergeTerminalResultTrace, messageStatusPatchForTerminalMerge, messageTimingPatchForMerge, nonBlockingProjection, normalizeAgentError, projectionFromResult, shouldClearCompletedTurnDiagnostics, terminalMessageTimingPatchForNormalize } from "../src/stores/workbench-message-projection-runtime.ts"; +import { clearRunnerTraceTransientDiagnostics, mergeTerminalResultTrace, messageStatusPatchForTerminalMerge, messageTimingPatchForMerge, nonBlockingProjection, normalizeAgentError, projectionFromResult, shouldClearCompletedTurnDiagnostics, terminalMessagePatchFromTurnResult, terminalMessageTimingPatchForNormalize } from "../src/stores/workbench-message-projection-runtime.ts"; test("terminal result merge preserves runner trace evidence and timing", () => { const previous: ChatMessage["runnerTrace"] = { @@ -82,3 +82,39 @@ test("message timing and terminal status patches stay independent from store sta const terminalPatch = terminalMessageTimingPatchForNormalize(result); assert.equal(terminalPatch.durationMs, 3500); }); + +test("terminal seal blocks stale running snapshots after session restore", () => { + const sealed = { + role: "agent", + status: "failed", + text: "provider stream disconnected", + traceAutoLifecycle: "terminal", + runnerTrace: { traceId: "trc_1", status: "failed" }, + timing: { startedAt: "2026-01-01T00:00:00.000Z", finishedAt: "2026-01-01T00:00:05.000Z", durationMs: 5000, valuesRedacted: true } + } as ChatMessage; + + assert.deepEqual(messageStatusPatchForTerminalMerge(sealed, "running", false), { status: "failed", traceAutoLifecycle: "terminal" }); + + const restored = { + role: "agent", + status: "running", + traceAutoLifecycle: "running", + traceId: "trc_1", + runnerTrace: { traceId: "trc_1", status: "running" }, + timing: { startedAt: "2026-01-01T00:00:00.000Z", valuesRedacted: true } + } as ChatMessage; + const terminal = terminalMessagePatchFromTurnResult(restored, { + traceId: "trc_1", + status: "failed", + terminal: true, + startedAt: "2026-01-01T00:00:00.000Z", + finishedAt: "2026-01-01T00:00:04.000Z", + durationMs: 4000, + error: { message: "provider stream disconnected" } + } as AgentChatResultResponse); + + assert.equal(terminal?.status, "failed"); + assert.equal(terminal?.traceAutoLifecycle, "terminal"); + assert.equal(terminal?.durationMs, 4000); + assert.equal(terminal?.error?.message, "provider stream disconnected"); +}); diff --git a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts index 95d306ae..e1d20e72 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-message-projection-runtime.ts @@ -264,11 +264,39 @@ function timestampMs(value: unknown): number | null { } export function messageStatusPatchForTerminalMerge(message: ChatMessage, resultStatus: string | null, terminal: boolean): Partial { + if (isTerminalMessageStatus(message.status) && (!terminal || !isTerminalMessageStatus(resultStatus))) return { status: message.status, traceAutoLifecycle: message.traceAutoLifecycle ?? "terminal" }; if (!terminal || isTerminalMessageStatus(message.status)) return {}; if (resultStatus && isTerminalMessageStatus(resultStatus)) return { status: resultStatus as ChatMessage["status"] }; return {}; } +export function terminalMessagePatchFromTurnResult(message: ChatMessage, result: AgentChatResultResponse): Partial | null { + const resultStatus = normalizedStatusText(result.status) ?? null; + const terminal = result.terminal === true || isTerminalMessageStatus(resultStatus); + if (!terminal) return null; + const resultError = normalizeAgentError(result.error ?? null); + const resultProjection = projectionFromResult(result); + const mergedRunnerTrace = mergeTerminalResultTrace(message.runnerTrace, result); + const clearCompletedDiagnostics = shouldClearCompletedTurnDiagnostics(resultStatus, resultError); + const runnerTrace = clearCompletedDiagnostics ? clearRunnerTraceTransientDiagnostics(mergedRunnerTrace) : mergedRunnerTrace; + const error = resultError ?? (clearCompletedDiagnostics ? null : normalizeAgentError(runnerTrace?.error ?? message.error)); + const projection = clearCompletedDiagnostics ? nonBlockingProjection(resultProjection) : resultProjection ?? runnerTrace.projection ?? message.projection ?? null; + const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); + return { + ...messageTimingPatchForMerge(message, result), + ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), + runnerTrace, + error, + projection, + projectionStatus: projection?.projectionStatus ?? null, + projectionHealth: projection?.projectionHealth ?? null, + blocker: projection?.blocker ?? null, + agentRun: agentRun ?? undefined, + traceAutoLifecycle: "terminal", + updatedAt: new Date().toISOString() + }; +} + export function firstPositiveFiniteNumber(...values: unknown[]): number | null { for (const value of values) { const number = firstFiniteNumber(value); diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index d16e060e..14b0ad66 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -35,6 +35,7 @@ import { messageNeedsTerminalDiagnostics, messageNeedsTraceHydration, messageStatusPatchForTerminalMerge, + terminalMessagePatchFromTurnResult, messageText, messageTimingPatch, messageTimingPatchForMerge, @@ -1796,10 +1797,38 @@ 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) ? await sealRestoredActiveTurnMessages(page.messages.map((message) => normalizeChatMessage(message as ChatMessage))) : base.messages; return { ...base, sessionId: id, messages: pageMessages, messageCount: page?.total ?? pageMessages?.length ?? base.messageCount }; } +async function sealRestoredActiveTurnMessages(source: ChatMessage[]): Promise { + const targets = source.filter(messageNeedsRestoredTurnSeal).slice(-3); + if (targets.length === 0) return source; + const patches = new Map>(); + await Promise.all(targets.map(async (message) => { + const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); + if (!traceId) return; + const response = await api.workbench.turn(traceId, 8000); + if (!response.ok || !response.data) return; + const patch = terminalMessagePatchFromTurnResult(message, response.data); + if (patch) patches.set(traceId, patch); + })); + if (patches.size === 0) return source; + return source.map((message) => { + const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); + const patch = traceId ? patches.get(traceId) : null; + return patch ? { ...message, ...patch } : message; + }); +} + +function messageNeedsRestoredTurnSeal(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + if (isTerminalMessageStatus(message.status)) return false; + const traceId = firstNonEmptyString(message.traceId, message.runnerTrace?.traceId); + if (!traceId) return false; + return isTraceActiveStatus(message.status) || message.traceAutoLifecycle === "running"; +} + function providerThreadIdForRequest(threadId: string | null | undefined): string | null { const value = firstNonEmptyString(threadId) ?? null; if (!value) return null;