diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts
index 6684e0c8..f3162950 100644
--- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts
+++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts
@@ -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 = ``;
let state = createScenarioState("baseline");
const sseClients = new Set();
@@ -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("", ` ${runtimeConfigScript}\n `);
+}
+
function safeStaticPath(path: string): string | null {
const normalized = resolve(distDir, `.${decodeURIComponent(path === "/" ? "/index.html" : path)}`);
return normalized === distDir || normalized.startsWith(`${distDir}${sep}`) ? normalized : null;
diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue
index 11951ee1..5bc10d15 100644
--- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue
+++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue
@@ -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(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[], { 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);
diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue
index 9c883ca5..ac67a294 100644
--- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue
+++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue
@@ -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 {
-
+
diff --git a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts
index df2e3b03..ab439b05 100644
--- a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts
+++ b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts
@@ -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,
diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts
index a87e65e0..4e585690 100644
--- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts
+++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts
@@ -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
};
diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts
index a8a4118e..33b62cc0 100644
--- a/web/hwlab-cloud-web/src/stores/workbench.ts
+++ b/web/hwlab-cloud-web/src/stores/workbench.ts
@@ -1676,7 +1676,7 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage {
const baseText = firstNonEmptyString(message.text, messageText((message as Record).content), messageText((message as Record).message));
const finalText = finalResponseText((message as Record).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 | null {
for (const message of [...messages].reverse()) {
if (message.role !== "agent") continue;
@@ -1850,27 +1842,8 @@ function messageTimingPatch(value: unknown): Partial {
}
function messageTimingPatchForMerge(message: ChatMessage, value: unknown): Partial {
- // 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);
- 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 {
@@ -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;
}
diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts
index 7a11a843..f976f65d 100644
--- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts
+++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts
@@ -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();
diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/message-timing.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/message-timing.spec.ts
index 21d4aaa7..0495d0e0 100644
--- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/message-timing.spec.ts
+++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/message-timing.spec.ts
@@ -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();