From c323fbdce5922ba730822fccf3cec68664f579e7 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:36:01 +0800 Subject: [PATCH] fix(workbench): keep trace timing monotonic (#1975) --- tools/src/hwlab-cli/trace-renderer.ts | 83 ++++++++++++++----- .../workbench/ConversationPanel.vue | 17 ++-- 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/tools/src/hwlab-cli/trace-renderer.ts b/tools/src/hwlab-cli/trace-renderer.ts index 2c2a1bf3..051102ec 100644 --- a/tools/src/hwlab-cli/trace-renderer.ts +++ b/tools/src/hwlab-cli/trace-renderer.ts @@ -17,12 +17,13 @@ export interface TraceDisplayRowsOptions { } export function traceDisplayRows(trace: Record = {}, events: TraceEvent[] = [], options: TraceDisplayRowsOptions = {}): TraceEventRow[] { - const effectiveTrace = traceWithInferredStart(trace, events); + const orderedEvents = traceEventsForDisplay(events); + const effectiveTrace = traceWithInferredStart(trace, orderedEvents); const rows: TraceEventRow[] = []; const renderedSourceEvents = new Set(); const renderedToolIdentities = new Set(); - for (let index = 0; index < events.length; index += 1) { - const event = events[index]; + for (let index = 0; index < orderedEvents.length; index += 1) { + const event = orderedEvents[index]; const sourceEventKey = traceSourceEventKey(event); if (sourceEventKey) { if (renderedSourceEvents.has(sourceEventKey)) continue; @@ -30,7 +31,7 @@ export function traceDisplayRows(trace: Record = {}, events: Tr } if (!event || isNoisyTraceEvent(event)) continue; if (isToolTraceEvent(event)) { - if (isSupersededToolStart(event, events, index)) continue; + if (isSupersededToolStart(event, orderedEvents, index)) continue; const identity = toolIdentity(event); if (identity) { if (renderedToolIdentities.has(identity)) continue; @@ -60,11 +61,11 @@ export function traceDisplayRows(trace: Record = {}, events: Tr } rows.push(traceDisplayRow(effectiveTrace, event, options)); } - const progressRow = traceBackendProgressSummaryRow(effectiveTrace, rows, events, options); + const progressRow = traceBackendProgressSummaryRow(effectiveTrace, rows, orderedEvents, options); if (progressRow) rows.push(progressRow); if (rows.length > 0) return rows; - if (events.length === 0) return []; - return events.filter((event) => !isSuppressedTraceEvent(event)).map((event) => traceDisplayRow(effectiveTrace, event, options)); + if (orderedEvents.length === 0) return []; + return orderedEvents.filter((event) => !isSuppressedTraceEvent(event)).map((event) => traceDisplayRow(effectiveTrace, event, options)); } export function renderTraceRowsMarkdown(rows: TraceEventRow[] = []): string { @@ -91,6 +92,35 @@ function renderTraceMessageRowMarkdown(row: TraceEventRow): string { return traceBoldMarkdown(row.header || `_No readable trace row body._`); } +function traceEventsForDisplay(events: TraceEvent[] = []): TraceEvent[] { + return [...events].sort((left, right) => { + const leftSeq = traceEventDisplaySeq(left); + const rightSeq = traceEventDisplaySeq(right); + if (leftSeq !== null || rightSeq !== null) { + if (leftSeq === null) return 1; + if (rightSeq === null) return -1; + if (leftSeq !== rightSeq) return leftSeq - rightSeq; + } + const leftTime = traceEventTimestampMs(left); + const rightTime = traceEventTimestampMs(right); + if (leftTime !== null || rightTime !== null) { + if (leftTime === null) return 1; + if (rightTime === null) return -1; + if (leftTime !== rightTime) return leftTime - rightTime; + } + return 0; + }); +} + +function traceEventDisplaySeq(event: TraceEvent | null | undefined): number | null { + return numberOrNull(event?.projectedSeq) ?? numberOrNull(event?.seq); +} + +function traceEventIdentityToken(event: TraceEvent): string | number | null { + const seq = traceEventDisplaySeq(event); + return seq ?? nonEmptyString(event.projectedSeq ?? event.seq); +} + function isToolTraceRow(row: TraceEventRow): boolean { return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header); } @@ -110,9 +140,10 @@ function traceToolCallRow(trace: Record, event: TraceEvent, opt const status = traceStatusToken(event); const toolName = traceToolName(event, command); const body = traceToolCallBody(event, command); + const eventKey = traceEventIdentityToken(event); return { - rowId: `tool:${event.itemId ?? event.seq ?? `${event.label ?? event.type ?? "tool"}:${event.createdAt ?? "unknown"}`}`, - seq: numberOrNull(event.seq), + rowId: `tool:${event.itemId ?? eventKey ?? `${event.label ?? event.type ?? "tool"}:${event.createdAt ?? "unknown"}`}`, + seq: traceEventDisplaySeq(event), tone: traceEventTone(event), header: `${traceEventClock(event, options)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${status} ${toolName}`.trim(), body, @@ -180,9 +211,10 @@ function traceAssistantMessageRow(trace: Record, event: TraceEv const text = cleanTraceDetailText(event.message ?? event.outputSummary ?? assistantStreamText(trace, event) ?? ""); const index = Number.isInteger(event.messageIndex) ? Number(event.messageIndex) : null; const count = Number.isInteger(event.messageCount) ? Number(event.messageCount) : null; + const eventKey = traceEventIdentityToken(event); return { - rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`, - seq: numberOrNull(event.seq), + rowId: `event:${eventKey ?? `${event.label ?? event.type ?? "assistant"}:${event.createdAt ?? "unknown"}`}`, + seq: traceEventDisplaySeq(event), tone: "ok", header: `${traceEventClock(event, options)} ${terminal ? "助手最终消息" : "助手消息"}${index ? ` ${index}${count ? `/${count}` : ""}` : ""}`, terminal: terminal ? true : undefined, @@ -192,9 +224,10 @@ function traceAssistantMessageRow(trace: Record, event: TraceEv } function traceFinalResponseRow(event: TraceEvent, finalText: string, options: TraceDisplayRowsOptions): TraceEventRow { + const eventKey = traceEventIdentityToken(event); return { - rowId: `trace-final-response:${event.seq ?? "completed"}`, - seq: numberOrNull(event.seq), + rowId: `trace-final-response:${eventKey ?? "completed"}`, + seq: traceEventDisplaySeq(event), tone: "ok", header: `${traceEventClock(event, options)} 助手最终消息`, terminal: true, @@ -209,9 +242,10 @@ function traceFinalResponseText(trace: Record): string | null { } function traceCompletionSummaryRow(trace: Record, event: TraceEvent, options: TraceDisplayRowsOptions): TraceEventRow { + const eventKey = traceEventIdentityToken(event); return { - rowId: `trace-completion:${event.seq ?? "turn"}`, - seq: numberOrNull(event.seq), + rowId: `trace-completion:${eventKey ?? "turn"}`, + seq: traceEventDisplaySeq(event), tone: traceEventTone(event), header: `${traceEventClock(event, options)} 轮次完成(总耗时 ${formatTraceDuration(traceRelativeMs(trace, event))})`, body: null @@ -224,7 +258,7 @@ function traceNoiseSummaryRow(trace: Record, events: TraceEvent const lastLabel = lastEvent ? readableTraceLabel(lastEvent) : "未观测"; return { rowId: "trace-noise-summary", - seq: numberOrNull(lastEvent?.seq), + seq: traceEventDisplaySeq(lastEvent), tone: lastEvent ? traceEventTone(lastEvent) : "source", header: `${traceEventClock(lastEvent, options)} Trace ${status},等待可读事件`, body: `已隐藏 ${events.length} 条 AgentRun backend 状态事件。最新原始事件:${lastLabel}。`, @@ -237,7 +271,7 @@ function traceBackendProgressSummaryRow(trace: Record, rows: Tr if (rows.some((row) => row.rowId.startsWith("tool:") || /助手|轮次完成/u.test(row.header))) return null; const lastRenderedSeq = Math.max(0, ...rows.map((row) => row.seq ?? 0).filter(Number.isFinite)); const hiddenProgress = events.filter((event) => { - const seq = numberOrNull(event.seq) ?? 0; + const seq = traceEventDisplaySeq(event) ?? 0; return seq > lastRenderedSeq && (isRequestTraceEvent(event) || isSetupTraceEvent(event) || isNoisyTraceEvent(event)); }); return hiddenProgress.length > 0 ? traceNoiseSummaryRow(trace, hiddenProgress, options) : null; @@ -245,9 +279,10 @@ function traceBackendProgressSummaryRow(trace: Record, rows: Tr function traceDisplayRow(trace: Record, event: TraceEvent, options: TraceDisplayRowsOptions): TraceEventRow { const label = readableTraceLabel(event); + const eventKey = traceEventIdentityToken(event); return { - rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`, - seq: numberOrNull(event.seq), + rowId: `event:${eventKey ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`, + seq: traceEventDisplaySeq(event), tone: traceEventTone(event), header: `${traceEventClock(event, options)} total=${formatTraceDuration(traceRelativeMs(trace, event))} ${traceStatusToken(event)} ${label}`.trim(), body: traceDisplayBody(event) @@ -464,6 +499,13 @@ function traceEventTimestamp(event: TraceEvent | null | undefined): string | nul return nonEmptyString(event.createdAt ?? event.ts ?? event.timestamp ?? event.observedAt); } +function traceEventTimestampMs(event: TraceEvent | null | undefined): number | null { + const timestamp = traceEventTimestamp(event); + if (!timestamp) return null; + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) ? parsed : null; +} + function traceRelativeMs(trace: Record, event: TraceEvent): number { if (typeof event.elapsedMs === "number") return event.elapsedMs; const start = Date.parse(String(trace.startedAt ?? trace.createdAt ?? "")); @@ -529,7 +571,8 @@ function compactTraceOneLine(value: unknown, limit = 220): string { } function numberOrNull(value: unknown): number | null { - return Number.isInteger(value) ? Number(value) : null; + const number = Number(value); + return Number.isInteger(number) ? number : null; } function nonEmptyString(value: unknown): string | null { diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 41fbebdd..2264a6c6 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -279,10 +279,10 @@ function sealedDisplayDurationMs(message: ChatMessage, durationMs: number | null const key = messageDurationFloorKey(message); displayedDurationFloorMs.delete(key); const previous = sealedDurationMs.get(key); - if (previous !== undefined) return previous; - if (durationMs === null) return null; - sealedDurationMs.set(key, durationMs); - return durationMs; + if (durationMs === null) return previous ?? null; + const next = previous === undefined ? durationMs : Math.max(previous, durationMs); + if (next !== previous) sealedDurationMs.set(key, next); + return next; } function messageDurationFloorKey(message: ChatMessage): string { @@ -307,7 +307,7 @@ function messageTimingForDisplay(message: ChatMessage): ChatMessage["timing"] { } function terminalMessageDurationMs(message: ChatMessage, timing: ChatMessage["timing"]): number | null { - const recorded = firstPositiveDurationMs(message.durationMs, timing?.durationMs, message.runnerTrace?.durationMs, message.runnerTrace?.timing?.durationMs); + const recorded = maxPositiveDurationMs(message.durationMs, timing?.durationMs, message.runnerTrace?.durationMs, message.runnerTrace?.timing?.durationMs); if (recorded !== null) return recorded; const elapsed = elapsedTerminalDurationMs(message, timing); if (elapsed !== null && elapsed > 0) return elapsed; @@ -342,12 +342,13 @@ function finiteDurationMs(value: unknown): number | null { return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null; } -function firstPositiveDurationMs(...values: unknown[]): 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) return duration; + if (duration !== null && duration > 0) maximum = maximum === null ? duration : Math.max(maximum, duration); } - return null; + return maximum; } function formatDuration(ms: number): string {