fix(workbench): remove frontend trace timing compensation

This commit is contained in:
lyon
2026-06-25 00:33:21 +08:00
parent 2651a6ee55
commit f40cd324fb
8 changed files with 54 additions and 190 deletions
@@ -1,7 +1,7 @@
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response; PJ2026-010403 API契约 draft-2026-06-18-r1.
// Responsibility: Same-origin fake Workbench API and static server for Playwright browser regression tests.
import { createReadStream, existsSync, statSync } from "node:fs";
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { extname, join, resolve, sep } from "node:path";
@@ -98,6 +98,7 @@ const terminalAssistantFinalText = [
].join("\n");
const canonicalTraceFinalText = "messageProjection 的 sealed final response 才是主消息正文。";
const traceDetailConflictText = "TRACE_DETAIL_CONFLICT_SHOULD_NOT_REPLACE_MESSAGE";
const runtimeConfigScript = `<script>\nwindow.HWLAB_CLOUD_WEB_CONFIG = {\n ...(window.HWLAB_CLOUD_WEB_CONFIG ?? {}),\n displayTime: window.HWLAB_CLOUD_WEB_CONFIG?.displayTime ?? { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" },\n workbench: {\n ...(window.HWLAB_CLOUD_WEB_CONFIG?.workbench ?? {}),\n traceTimeline: window.HWLAB_CLOUD_WEB_CONFIG?.workbench?.traceTimeline ?? { autoExpandRunning: false, autoCollapseTerminal: false }\n }\n};\n</script>`;
let state = createScenarioState("baseline");
const sseClients = new Set<ServerResponse>();
@@ -1782,6 +1783,8 @@ function sse(request: IncomingMessage, response: ServerResponse, url: URL): void
const event = (trace.events as JsonRecord[]).at(-1) ?? { status: "completed", terminal: true };
writeSse(response, "workbench.trace.event", { type: "trace.event", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", event, snapshot: trace });
finishRunningNoFinalResponse(trace);
const message = runningAgentMessageSnapshot();
if (message) writeSse(response, "workbench.message.snapshot", { type: "message.snapshot", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", message });
writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", turn: turnPayload("trc_running") });
return;
}
@@ -1790,8 +1793,10 @@ function sse(request: IncomingMessage, response: ServerResponse, url: URL): void
const event = { seq: 3, createdAt: new Date().toISOString(), label: "agentrun:assistant:message", type: "assistant_message", status: terminalStatus, replyAuthority: true, final: true, message: terminalText, terminal: true };
writeSse(response, "workbench.trace.event", { type: "trace.event", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", event, snapshot: { traceId: "trc_running", sessionId: "ses_running", threadId: "thr_running", status: terminalStatus, events: [event], eventCount: 3, fullTraceLoaded: true, finalResponse: { text: terminalText, status: terminalStatus } } });
finishRunningSession(terminalStatus, terminalText);
const message = runningAgentMessageSnapshot();
if (message) writeSse(response, "workbench.message.snapshot", { type: "message.snapshot", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", message });
writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", turn: turnPayload("trc_running") });
}, state.scenarioId === "progress-only-final-response" ? 5_000 : 350);
}, state.scenarioId === "progress-only-final-response" ? 5_000 : state.scenarioId === "event-replay" ? 1_500 : 350);
}
function broadcastSse(eventName: string, payload: JsonRecord): void {
@@ -1824,6 +1829,12 @@ function finishRunningNoFinalResponse(trace: JsonRecord = completedNoFinalTrace(
session.messages = (session.messages ?? []).map((message) => message.role === "agent" ? { ...message, text: "", status: "completed", runnerTrace: trace } : message);
}
function runningAgentMessageSnapshot(): JsonRecord | null {
const session = sessionById("ses_running");
const message = session?.messages?.find((item) => item.role === "agent" && item.traceId === "trc_running");
return message ?? null;
}
function stateSummary(): JsonRecord {
return { scenarioId: state.scenarioId, selectedSessionId: state.selectedSessionId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, chatRequests: state.chatRequests, staleTraceId: state.staleTraceId, sessions: state.sessions.map((item) => ({ sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId, hidden: item.hidden === true })) };
}
@@ -1950,9 +1961,18 @@ function staticFile(response: ServerResponse, path: string): void {
const filePath = safeStaticPath(path);
const target = filePath && existsSync(filePath) && statSync(filePath).isFile() ? filePath : join(distDir, "index.html");
response.writeHead(200, { "content-type": contentType(target) });
if (extname(target) === ".html") {
response.end(injectRuntimeConfig(readFileSync(target, "utf8")));
return;
}
createReadStream(target).pipe(response);
}
function injectRuntimeConfig(html: string): string {
if (html.includes("HWLAB_CLOUD_WEB_CONFIG")) return html;
return html.replace("</head>", ` ${runtimeConfigScript}\n </head>`);
}
function safeStaticPath(path: string): string | null {
const normalized = resolve(distDir, `.${decodeURIComponent(path === "/" ? "/index.html" : path)}`);
return normalized === distDir || normalized.startsWith(`${distDir}${sep}`) ? normalized : null;
@@ -7,7 +7,7 @@ import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
import { formatDisplayClock } from "@/config/runtime";
import { traceDisplayRows, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string; autoExpanded?: boolean | null; hideSealedFinalResponse?: boolean }>();
const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string; autoExpanded?: boolean | null }>();
const listRef = ref<HTMLOListElement | null>(null);
const expanded = ref(false);
@@ -16,7 +16,7 @@ const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useBottom
const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
const rawReadableRows = computed(() => traceDisplayRows(traceRecord(props.trace), events.value as Record<string, unknown>[], { formatClock: formatDisplayClock }));
const readableRows = computed(() => filterReadableRows(rawReadableRows.value, props.hideSealedFinalResponse === true));
const readableRows = computed(() => rawReadableRows.value);
const projectionDiagnosticLabel = computed(() => {
const projection = props.trace?.projection ?? null;
const health = projection?.projectionHealth ?? props.trace?.projectionHealth;
@@ -83,18 +83,6 @@ function rowIsTool(row: TraceEventRow): boolean {
return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header);
}
function filterReadableRows(rows: TraceEventRow[], hideSealedFinalResponse: boolean): TraceEventRow[] {
if (!hideSealedFinalResponse) return rows;
return rows.filter((row) => !traceRowDuplicatesSealedFinal(row));
}
function traceRowDuplicatesSealedFinal(row: TraceEventRow): boolean {
if (rowIsTool(row)) return false;
if (row.rowId.startsWith("trace-final-response:")) return true;
const rowText = [row.rowId, row.header, row.body, row.preview].map((value) => String(value ?? "")).join("\n");
return /(?:助手(?:最终)?消息|assistant\s+(?:final\s+)?message)/iu.test(rowText);
}
function toolCommandPreview(row: TraceEventRow): string | null {
if (!rowIsTool(row)) return null;
const preview = singleLinePreview(row.preview);
@@ -247,7 +247,7 @@ function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] {
function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null {
if (message.role !== "agent") return null;
const timing = messageTimingForDisplay(message);
const durationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : terminalMessageDurationMs(message, timing);
const durationMs = isRunningMessage(message) ? durationSince(timing?.startedAt) : terminalMessageDurationMs(timing);
if (durationMs === null) return null;
const value = formatDuration(durationMs);
return { text: `耗时 ${value}`, label: `总耗时:${value}` };
@@ -270,33 +270,8 @@ function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] {
return message.timing ?? null;
}
function terminalMessageDurationMs(message: ChatMessage, timing: ChatMessage["timing"]): number | null {
const elapsed = elapsedTerminalDurationMs(message, timing);
if (elapsed !== null && elapsed > 0) return ceilDisplayDurationSecond(elapsed);
const recorded = sealedTerminalRecordedDurationMs(message, timing);
if (recorded !== null) return ceilDisplayDurationSecond(recorded);
return null;
}
function ceilDisplayDurationSecond(ms: number): number {
if (!Number.isFinite(ms) || ms <= 0) return ms;
return Math.ceil(ms / 1000) * 1000;
}
function sealedTerminalRecordedDurationMs(message: ChatMessage, timing: ChatMessage["timing"]): number | null {
void message;
const messageHasTerminalTimestamp = timestampMs(timing?.finishedAt) !== null;
return maxPositiveDurationMs(
messageHasTerminalTimestamp ? timing?.durationMs : null,
);
}
function elapsedTerminalDurationMs(message: ChatMessage, timing: ChatMessage["timing"]): number | null {
void message;
const startedAt = timestampMs(timing?.startedAt);
const endedAt = timestampMs(timing?.finishedAt);
if (startedAt === null || endedAt === null || endedAt < startedAt) return null;
return Math.trunc(endedAt - startedAt);
function terminalMessageDurationMs(timing: ChatMessage["timing"]): number | null {
return finiteDurationMs(timing?.durationMs);
}
function durationSince(timestamp: unknown): number | null {
@@ -317,15 +292,6 @@ function finiteDurationMs(value: unknown): number | null {
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
}
function maxPositiveDurationMs(...values: unknown[]): number | null {
let maximum: number | null = null;
for (const value of values) {
const duration = finiteDurationMs(value);
if (duration !== null && duration > 0) maximum = maximum === null ? duration : Math.max(maximum, duration);
}
return maximum;
}
function formatDuration(ms: number): string {
const seconds = ms > 0 && ms < 1000 ? 1 : Math.max(0, Math.floor(ms / 1000));
if (seconds < 60) return `${seconds}`;
@@ -364,7 +330,7 @@ function formatDuration(ms: number): string {
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(message)">!</button>
</div>
</header>
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="traceForDisplay(message)" :auto-expanded="traceAutoExpanded(message)" :storage-key="traceStorageKey(message)" :hide-sealed-final-response="hasSealedCompletedText(message)" />
<TraceTimeline v-if="message.role === 'agent' && message.runnerTrace" :trace="traceForDisplay(message)" :auto-expanded="traceAutoExpanded(message)" :storage-key="traceStorageKey(message)" />
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
<MessageMarkdown v-if="showMessageText(message)" class="message-text" :source="visibleMessageText(message)" />
<ApiErrorDiagnostic v-if="messageHasDiagnostic(message)" class="message-diagnostic projection-diagnostic" :error="messageDiagnosticText(message)" :api-error="messageApiError(message)" :diagnostic="messageErrorDiagnostic(message)" compact />
@@ -133,37 +133,20 @@ export function mergeRunnerTrace(previous: ChatMessage["runnerTrace"], next: Non
};
}
type TraceTimingMergeOptions = {
preserveVisibleTerminalDuration?: boolean;
};
function mergeTraceTimingProjection(previous: unknown, next: unknown, options: TraceTimingMergeOptions = {}): TraceSnapshot["timing"] | null {
function mergeTraceTimingProjection(previous: unknown, next: unknown): TraceSnapshot["timing"] | null {
const previousTiming = traceTimingCandidate(previous);
const nextTiming = traceTimingCandidate(next);
if (!previousTiming && !nextTiming) return null;
if (!previousTiming) return nextTiming;
if (!nextTiming) return previousTiming;
const startedAt = earliestTraceTimestamp(previousTiming.startedAt, nextTiming.startedAt);
const lastEventAt = latestTraceTimestamp(previousTiming.lastEventAt, nextTiming.lastEventAt);
const finishedAt = latestTraceTimestamp(previousTiming.finishedAt, nextTiming.finishedAt);
const runningElapsedDurationMs = traceElapsedMs(startedAt, lastEventAt);
const terminalElapsedDurationMs = traceElapsedMs(startedAt, finishedAt);
const terminalTiming = traceTimingIsTerminal(next) || finishedAt !== null;
const previousTerminalDurationMs = previousTiming.finishedAt ? previousTiming.durationMs : null;
const preservedVisibleTerminalDurationMs = options.preserveVisibleTerminalDuration && !finishedAt
? firstTraceDuration(previousTiming.durationMs)
: null;
const durationMs = terminalTiming
? maxTraceDuration(finishedAt ? nextTiming.durationMs : null, terminalElapsedDurationMs, previousTerminalDurationMs, preservedVisibleTerminalDurationMs)
: maxTraceDuration(previousTiming.durationMs, nextTiming.durationMs, runningElapsedDurationMs);
return {
...previousTiming,
...nextTiming,
startedAt,
lastEventAt,
finishedAt,
durationMs,
observedAt: latestTraceTimestamp(previousTiming.observedAt, nextTiming.observedAt),
startedAt: nextTiming.startedAt ?? previousTiming.startedAt ?? null,
lastEventAt: nextTiming.lastEventAt ?? previousTiming.lastEventAt ?? null,
finishedAt: nextTiming.finishedAt ?? previousTiming.finishedAt ?? null,
durationMs: firstTraceDuration(nextTiming.durationMs, previousTiming.durationMs),
observedAt: nextTiming.observedAt ?? previousTiming.observedAt ?? null,
lastEventAgeMs: null,
valuesRedacted: previousTiming.valuesRedacted !== false && nextTiming.valuesRedacted !== false
};
@@ -177,7 +160,7 @@ function traceTimingCandidate(value: unknown): TraceSnapshot["timing"] | null {
const lastEventAt = firstTraceTimestamp(timing?.lastEventAt, record?.lastEventAt);
const finishedAt = firstTraceTimestamp(timing?.finishedAt, record?.finishedAt);
const observedAt = firstTraceTimestamp(timing?.observedAt, record?.observedAt);
const durationMs = maxTraceDuration(timing?.durationMs, record?.durationMs);
const durationMs = firstTraceDuration(timing?.durationMs, record?.durationMs);
const lastEventAgeMs = finiteTraceDuration(timing?.lastEventAgeMs ?? record?.lastEventAgeMs);
if (!startedAt && !lastEventAt && !finishedAt && !observedAt && durationMs === null && lastEventAgeMs === null) return null;
return { ...(timing ?? {}), startedAt, lastEventAt, finishedAt, observedAt, durationMs, lastEventAgeMs, valuesRedacted: timing?.valuesRedacted !== false };
@@ -197,42 +180,6 @@ function firstTraceTimestamp(...values: unknown[]): string | null {
return null;
}
function earliestTraceTimestamp(...values: unknown[]): string | null {
return selectTraceTimestamp("earliest", values);
}
function latestTraceTimestamp(...values: unknown[]): string | null {
return selectTraceTimestamp("latest", values);
}
function selectTraceTimestamp(mode: "earliest" | "latest", values: unknown[]): string | null {
let selected: { value: string; ms: number } | null = null;
for (const value of values) {
const timestamp = firstTraceTimestamp(value);
if (!timestamp) continue;
const ms = Date.parse(timestamp);
if (!selected || (mode === "earliest" ? ms < selected.ms : ms > selected.ms)) selected = { value: timestamp, ms };
}
return selected?.value ?? null;
}
function traceElapsedMs(startedAt: unknown, endedAt: unknown): number | null {
const start = startedAt == null ? NaN : Date.parse(String(startedAt));
const end = endedAt == null ? NaN : Date.parse(String(endedAt));
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null;
return Math.trunc(end - start);
}
function maxTraceDuration(...values: unknown[]): number | null {
let max: number | null = null;
for (const value of values) {
const duration = finiteTraceDuration(value);
if (duration === null) continue;
if (max === null || duration > max) max = duration;
}
return max;
}
function firstTraceDuration(...values: unknown[]): number | null {
for (const value of values) {
const duration = finiteTraceDuration(value);
@@ -246,15 +193,6 @@ function finiteTraceDuration(value: unknown): number | null {
return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
}
function traceTimingWasRunning(value: unknown): boolean {
const record = traceRecord(value);
if (!record) return false;
const status = typeof record.status === "string" ? record.status.trim().toLowerCase() : "";
if (["completed", "failed", "cancelled", "canceled", "error"].includes(status)) return false;
const timing = traceTimingCandidate(value);
return !timing?.finishedAt;
}
function traceTimingIsTerminal(value: unknown): boolean {
const record = traceRecord(value);
const status = typeof record?.status === "string" ? record.status.trim().toLowerCase() : "";
@@ -308,17 +246,17 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
blocker: trace.blocker ?? terminal.blocker ?? null
};
const events = Array.isArray(trace.events) ? trace.events : [];
const timing = mergedTrace.timing ?? terminal.timing ?? terminal.runnerTrace?.timing ?? null;
const timing = mergedTrace.timing ?? terminal.timing ?? null;
return {
...terminal,
traceId: mergedTrace.traceId ?? terminal.traceId,
sessionId: mergedTrace.sessionId ?? terminal.sessionId,
threadId: mergedTrace.threadId ?? terminal.threadId,
timing,
startedAt: mergedTrace.startedAt ?? timing?.startedAt ?? terminal.startedAt ?? terminal.runnerTrace?.startedAt,
lastEventAt: mergedTrace.lastEventAt ?? timing?.lastEventAt ?? terminal.lastEventAt ?? terminal.runnerTrace?.lastEventAt,
finishedAt: mergedTrace.finishedAt ?? timing?.finishedAt ?? terminal.finishedAt ?? terminal.runnerTrace?.finishedAt,
durationMs: mergedTrace.durationMs ?? timing?.durationMs ?? terminal.durationMs ?? terminal.runnerTrace?.durationMs,
startedAt: mergedTrace.startedAt ?? timing?.startedAt ?? terminal.startedAt,
lastEventAt: mergedTrace.lastEventAt ?? timing?.lastEventAt ?? terminal.lastEventAt,
finishedAt: mergedTrace.finishedAt ?? timing?.finishedAt ?? terminal.finishedAt,
durationMs: mergedTrace.durationMs ?? timing?.durationMs ?? terminal.durationMs,
agentRun: mergedTrace.agentRun ?? terminal.agentRun,
error: mergedTrace.error ?? terminal.error,
assistantText: terminal.assistantText,
@@ -403,7 +341,7 @@ export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise
function resultToTraceSnapshot(traceId: string, result: AgentChatResultResponse, eventSource = "trace-api"): TraceSnapshot {
const events = Array.isArray(result.events) ? result.events : Array.isArray(result.traceEvents) ? result.traceEvents : [];
const lastEvent = events.at(-1);
const timing = result.timing ?? result.runnerTrace?.timing ?? null;
const timing = result.timing ?? null;
return {
traceId: result.traceId ?? traceId,
status: result.status,
@@ -424,10 +362,10 @@ function resultToTraceSnapshot(traceId: string, result: AgentChatResultResponse,
traceSummary: result.traceSummary,
error: result.error,
timing,
startedAt: result.startedAt ?? timing?.startedAt ?? result.runnerTrace?.startedAt ?? null,
lastEventAt: result.lastEventAt ?? timing?.lastEventAt ?? result.runnerTrace?.lastEventAt ?? null,
finishedAt: result.finishedAt ?? timing?.finishedAt ?? result.runnerTrace?.finishedAt ?? null,
durationMs: result.durationMs ?? timing?.durationMs ?? result.runnerTrace?.durationMs ?? null,
startedAt: result.startedAt ?? timing?.startedAt ?? null,
lastEventAt: result.lastEventAt ?? timing?.lastEventAt ?? null,
finishedAt: result.finishedAt ?? timing?.finishedAt ?? null,
durationMs: result.durationMs ?? timing?.durationMs ?? null,
projection: result.projection ?? result.runnerTrace?.projection ?? null,
projectionStatus: result.projectionStatus ?? result.runnerTrace?.projectionStatus ?? null,
projectionHealth: result.projectionHealth ?? result.runnerTrace?.projectionHealth ?? null,
@@ -444,7 +382,7 @@ function mergeTraceSnapshots(previous: TraceSnapshot | null, next: TraceSnapshot
const previousEvents = Array.isArray(previous.events) ? previous.events : [];
const nextEvents = Array.isArray(next.events) ? next.events : [];
const events = mergeTraceEvents(previousEvents, nextEvents);
const timing = mergeTraceTimingProjection(previous, next, { preserveVisibleTerminalDuration: traceTimingWasRunning(previous) && traceTimingIsTerminal(next) });
const timing = mergeTraceTimingProjection(previous, next);
return {
...previous,
...next,
@@ -461,14 +399,7 @@ function mergeTraceSnapshots(previous: TraceSnapshot | null, next: TraceSnapshot
}
function traceSnapshotWithTurnStatus(trace: TraceSnapshot, turn: TraceSnapshot): TraceSnapshot {
const turnTiming = traceTimingCandidate(turn);
const turnIsTerminal = traceTimingIsTerminal(turn) || isTerminalStatus(turn.status);
// Running card timing belongs to the turn status projection. Trace pages may
// hydrate older detail rows later; they must not move the visible running
// card's startedAt/lastEventAt source and create multi-second jumps.
const timing = !turnIsTerminal && turnTiming
? turnTiming
: mergeTraceTimingProjection(trace, turn, { preserveVisibleTerminalDuration: traceTimingWasRunning(trace) && traceTimingIsTerminal(turn) });
const timing = mergeTraceTimingProjection(trace, turn);
return {
...trace,
traceId: trace.traceId ?? turn.traceId,
@@ -165,7 +165,7 @@ function mergeMessageSnapshot(existing: ChatMessage, incoming: ChatMessage): Cha
return {
...existing,
...incoming,
runnerTrace: existing.runnerTrace ?? incoming.runnerTrace ?? null,
runnerTrace: incoming.runnerTrace ?? existing.runnerTrace ?? null,
traceAutoLifecycle: existing.traceAutoLifecycle,
updatedAt: incoming.updatedAt ?? existing.updatedAt
};
+3 -45
View File
@@ -1676,7 +1676,7 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage {
const baseText = firstNonEmptyString(message.text, messageText((message as Record<string, unknown>).content), messageText((message as Record<string, unknown>).message));
const finalText = finalResponseText((message as Record<string, unknown>).finalResponse);
const errorText = agentErrorDisplayText(error);
const status = normalizeAgentMessageStatusFromEvidence(role, rawStatus, runnerTrace);
const status = rawStatus;
const text = role === "agent"
? isTerminalMessageStatus(status)
? projectedAgentMessageText({ status, finalText, errorText, baseText })
@@ -1686,14 +1686,6 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage {
return { ...message, ...messageTimingPatch(message), role, text, id: messageId, messageId, title: normalizeWorkbenchMessageTitle(role, message.title), createdAt: message.createdAt ?? new Date().toISOString(), status, runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined };
}
function normalizeAgentMessageStatusFromEvidence(role: ChatMessage["role"], status: ChatMessage["status"], runnerTrace: ChatMessage["runnerTrace"]): ChatMessage["status"] {
if (role !== "agent") return status;
const traceStatus = normalizedStatusText(runnerTrace?.status);
if (isTerminalMessageStatus(traceStatus)) return traceStatus as ChatMessage["status"];
if (isTerminalMessageStatus(status)) return status;
return status;
}
function activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record<string, TurnStatusAuthority>): string | null {
for (const message of [...messages].reverse()) {
if (message.role !== "agent") continue;
@@ -1850,27 +1842,8 @@ function messageTimingPatch(value: unknown): Partial<ChatMessage> {
}
function messageTimingPatchForMerge(message: ChatMessage, value: unknown): Partial<ChatMessage> {
// Trace/result hydration may update runnerTrace and TraceTimeline, but the
// main Code Agent card timing is a durable message projection field. Do not
// let trace snapshots or terminal result polling rewrite message.timing;
// otherwise visible card elapsed can jump or diverge from the sealed message
// projection when trace rows arrive out of order.
const current = messageTimingPatch(message);
const currentTiming = current.timing ?? null;
const record = recordValue(value);
const incomingStatus = normalizedStatusText(firstNonEmptyString(record?.status));
const incomingTerminal = record?.terminal === true || isTerminalMessageStatus(incomingStatus);
if (!incomingTerminal || isTerminalMessageStatus(message.status) || currentTiming?.finishedAt || currentTiming?.durationMs != null) return current;
const incoming = normalizeTimingProjection(value);
const terminalTiming = incoming ?? ({} as Partial<WorkbenchTurnTimingProjection>);
const startedAt = firstNonEmptyString(currentTiming?.startedAt, terminalTiming.startedAt) ?? null;
const terminalFinishedAt = firstNonEmptyString(terminalTiming.finishedAt, record?.finishedAt, record?.completedAt, record?.updatedAt, terminalTiming.lastEventAt, currentTiming?.finishedAt, currentTiming?.lastEventAt) ?? null;
const durationMs = firstFiniteNumber(terminalTiming.durationMs, record?.durationMs) ?? inferredDurationMs(startedAt, terminalFinishedAt) ?? currentTiming?.durationMs ?? null;
const inferredFinishedAt = inferFinishedAtFromDuration(startedAt, durationMs);
const finishedAt = firstNonEmptyString(terminalTiming.finishedAt, terminalFinishedAt, inferredFinishedAt, terminalTiming.lastEventAt, currentTiming?.finishedAt) ?? null;
const lastEventAt = firstNonEmptyString(terminalTiming.lastEventAt, finishedAt, currentTiming?.lastEventAt, startedAt) ?? null;
const timing = { ...currentTiming, ...terminalTiming, startedAt, lastEventAt, finishedAt, durationMs, valuesRedacted: terminalTiming.valuesRedacted !== false && currentTiming?.valuesRedacted !== false } as WorkbenchTurnTimingProjection;
return { ...current, timing, startedAt, lastEventAt, finishedAt, durationMs };
void value;
return messageTimingPatch(message);
}
function messageStatusPatchForTerminalMerge(message: ChatMessage, resultStatus: string | null, terminal: boolean): Partial<ChatMessage> {
@@ -1879,20 +1852,6 @@ function messageStatusPatchForTerminalMerge(message: ChatMessage, resultStatus:
return {};
}
function inferFinishedAtFromDuration(startedAt: unknown, durationMs: number | null): string | null {
if (durationMs === null || !Number.isFinite(durationMs) || durationMs < 0) return null;
const startedAtMs = timestampMs(startedAt);
if (startedAtMs === null) return null;
return new Date(startedAtMs + Math.trunc(durationMs)).toISOString();
}
function inferredDurationMs(startedAt: unknown, finishedAt: unknown): number | null {
const startedAtMs = timestampMs(startedAt);
const finishedAtMs = timestampMs(finishedAt);
if (startedAtMs === null || finishedAtMs === null || finishedAtMs < startedAtMs) return null;
return Math.trunc(finishedAtMs - startedAtMs);
}
function firstPositiveFiniteNumber(...values: unknown[]): number | null {
for (const value of values) {
const number = firstFiniteNumber(value);
@@ -2091,7 +2050,6 @@ function messageNeedsTraceHydration(message: ChatMessage): boolean {
const trace = message.runnerTrace;
const events = Array.isArray(trace?.events) ? trace.events : [];
const eventCount = firstFiniteNumber(trace?.eventCount) ?? events.length;
if (messageHasCompletedFinalResponse(message) && events.length === 0) return false;
if (trace?.fullTraceLoaded === true && trace.eventsCompacted !== true) return events.length === 0 && (eventCount > 0 || isTraceActiveStatus(trace?.status) || isTraceActiveStatus(message.status));
return events.length === 0 || trace?.eventsCompacted === true || trace?.fullTraceLoaded !== true;
}
@@ -6,7 +6,6 @@ test.use({ scenarioId: "event-replay" });
test("SSE terminal event and REST gap fill replay to the same terminal UI", async ({ page }, testInfo) => {
await gotoWorkbench(page);
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`)).toBeVisible();
await expect(page.locator(`${selectors.traceTimeline}[data-status="running"]`)).toBeVisible();
await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`)).toContainText("事件重放后完成");
await expect(page.locator(`${selectors.traceTimeline}[data-status="completed"]`)).toBeVisible();
@@ -2,7 +2,9 @@ import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors, sessionTab } from "../fixtures/selectors";
test("Code Agent message headers render canonical timing metadata", async ({ page }, testInfo) => {
await page.clock.install({ time: new Date("2026-06-17T09:39:45.000Z") });
const baseTime = new Date("2026-06-17T09:39:45.000Z");
await page.clock.install({ time: baseTime });
await page.clock.pauseAt(baseTime);
await gotoWorkbench(page, "/workbench/sessions/ses_running");
const running = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).first();