fix: seal restored workbench terminal turns

This commit is contained in:
UniDesk Codex
2026-07-01 01:16:45 +08:00
parent 303a839ae5
commit 1fdd50b7bb
3 changed files with 95 additions and 2 deletions
@@ -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");
});
@@ -264,11 +264,39 @@ function timestampMs(value: unknown): number | null {
}
export function messageStatusPatchForTerminalMerge(message: ChatMessage, resultStatus: string | null, terminal: boolean): Partial<ChatMessage> {
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<ChatMessage> | 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);
+30 -1
View File
@@ -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<ChatMessage[]> {
const targets = source.filter(messageNeedsRestoredTurnSeal).slice(-3);
if (targets.length === 0) return source;
const patches = new Map<string, Partial<ChatMessage>>();
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;