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
@@ -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;
}