fix(workbench): keep turn timing monotonic at completion (#1997)

This commit is contained in:
Lyon
2026-06-24 00:55:09 +08:00
committed by GitHub
parent 21f349f3b1
commit 0faea26d9c
3 changed files with 30 additions and 37 deletions
+8 -2
View File
@@ -19,6 +19,8 @@ export interface TraceDisplayRowsOptions {
export function traceDisplayRows(trace: Record<string, unknown> = {}, events: TraceEvent[] = [], options: TraceDisplayRowsOptions = {}): TraceEventRow[] {
const orderedEvents = traceEventsForDisplay(events);
const effectiveTrace = traceWithInferredStart(trace, orderedEvents);
const finalResponseText = traceFinalResponseText(effectiveTrace);
const hasCompletionEvent = orderedEvents.some(isCompletionTraceEvent);
const rows: TraceEventRow[] = [];
const renderedSourceEvents = new Set<string>();
const renderedToolIdentities = new Set<string>();
@@ -47,7 +49,12 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
continue;
}
if (isTerminalAssistantTraceEvent(event)) {
rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: true }, options));
if (finalResponseText) {
if (hasCompletionEvent) continue;
rows.push(traceFinalResponseRow(event, finalResponseText, options));
} else {
rows.push(traceAssistantMessageRow(effectiveTrace, event, { terminal: true }, options));
}
continue;
}
if (isAssistantTraceEvent(event)) {
@@ -55,7 +62,6 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
continue;
}
if (isCompletionTraceEvent(event)) {
const finalResponseText = traceFinalResponseText(effectiveTrace);
rows.push(finalResponseText ? traceFinalResponseRow(event, finalResponseText, options) : traceCompletionSummaryRow(effectiveTrace, event, options));
continue;
}
@@ -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; sealedFinalText?: string }>();
const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string; autoExpanded?: boolean | null; hideSealedFinalResponse?: boolean }>();
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.sealedFinalText));
const readableRows = computed(() => filterReadableRows(rawReadableRows.value, props.hideSealedFinalResponse === true));
const projectionDiagnosticLabel = computed(() => {
const projection = props.trace?.projection ?? null;
const health = projection?.projectionHealth ?? props.trace?.projectionHealth;
@@ -83,39 +83,15 @@ function rowIsTool(row: TraceEventRow): boolean {
return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header);
}
function filterReadableRows(rows: TraceEventRow[], sealedFinalText: string | undefined): TraceEventRow[] {
const finalText = normalizeComparableText(sealedFinalText);
if (!finalText || finalText.length < 24) return rows;
return rows.filter((row) => !assistantRowDuplicatesFinal(row, finalText));
function filterReadableRows(rows: TraceEventRow[], hideSealedFinalResponse: boolean): TraceEventRow[] {
if (!hideSealedFinalResponse) return rows;
return rows.filter((row) => !traceRowDuplicatesSealedFinal(row));
}
function assistantRowDuplicatesFinal(row: TraceEventRow, finalText: string): boolean {
function traceRowDuplicatesSealedFinal(row: TraceEventRow): boolean {
if (rowIsTool(row)) return false;
const rowTextRaw = [row.header, row.preview, row.body].filter(Boolean).join("\n");
if (!/(?:助手消息|assistant\s+message|assistant)/iu.test(rowTextRaw)) return false;
const rowText = normalizeComparableText(rowTextRaw);
if (!rowText || rowText.length < 24) return true;
return finalText.includes(rowText) || rowText.includes(finalText) || longestSharedSubstringLength(finalText, rowText) >= Math.min(80, Math.floor(Math.min(finalText.length, rowText.length) * 0.7));
}
function normalizeComparableText(value: unknown): string {
return String(value ?? "").replace(/\s+/gu, " ").trim();
}
function longestSharedSubstringLength(a: string, b: string): number {
const left = a.length <= b.length ? a : b;
const right = a.length <= b.length ? b : a;
const max = Math.min(left.length, 320);
let best = 0;
for (let start = 0; start < max; start += 1) {
for (let end = Math.min(max, start + 180); end > start + best; end -= 1) {
if (right.includes(left.slice(start, end))) {
best = end - start;
break;
}
}
}
return best;
if (row.rowId.startsWith("trace-final-response:")) return true;
return row.terminal === true && /(?:助手最终消息|assistant\s+(?:final\s+)?message)/iu.test(String(row.header ?? ""));
}
function toolCommandPreview(row: TraceEventRow): string | null {
@@ -277,14 +277,25 @@ function monotonicDisplayDurationMs(message: ChatMessage, durationMs: number | n
function sealedDisplayDurationMs(message: ChatMessage, durationMs: number | null): number | null {
const key = messageDurationFloorKey(message);
const runningFloor = displayedDurationFloorMs.get(key);
const sealedFloor = sealedDurationMs.get(key);
const previous = maxDefinedDurationMs(runningFloor, sealedFloor);
displayedDurationFloorMs.delete(key);
const previous = sealedDurationMs.get(key);
if (durationMs === null) return previous ?? null;
const next = previous === undefined ? durationMs : Math.max(previous, durationMs);
const next = previous === null ? durationMs : Math.max(previous, durationMs);
if (next !== previous) sealedDurationMs.set(key, next);
return next;
}
function maxDefinedDurationMs(...values: Array<number | undefined>): number | null {
let maximum: number | null = null;
for (const value of values) {
if (typeof value !== "number" || !Number.isFinite(value)) continue;
maximum = maximum === null ? value : Math.max(maximum, value);
}
return maximum;
}
function messageDurationFloorKey(message: ChatMessage): string {
return firstNonEmptyString(message.id, message.runnerTrace?.traceId, message.traceId) ?? "unknown-agent-message";
}
@@ -403,7 +414,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)" :sealed-final-text="hasSealedCompletedText(message) ? visibleMessageText(message) : ''" />
<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)" />
<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 />