fix: seal restored workbench terminal turns
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user