fix(workbench): keep turn timing monotonic at completion (#1997)
This commit is contained in:
@@ -19,6 +19,8 @@ export interface TraceDisplayRowsOptions {
|
|||||||
export function traceDisplayRows(trace: Record<string, unknown> = {}, events: TraceEvent[] = [], options: TraceDisplayRowsOptions = {}): TraceEventRow[] {
|
export function traceDisplayRows(trace: Record<string, unknown> = {}, events: TraceEvent[] = [], options: TraceDisplayRowsOptions = {}): TraceEventRow[] {
|
||||||
const orderedEvents = traceEventsForDisplay(events);
|
const orderedEvents = traceEventsForDisplay(events);
|
||||||
const effectiveTrace = traceWithInferredStart(trace, orderedEvents);
|
const effectiveTrace = traceWithInferredStart(trace, orderedEvents);
|
||||||
|
const finalResponseText = traceFinalResponseText(effectiveTrace);
|
||||||
|
const hasCompletionEvent = orderedEvents.some(isCompletionTraceEvent);
|
||||||
const rows: TraceEventRow[] = [];
|
const rows: TraceEventRow[] = [];
|
||||||
const renderedSourceEvents = new Set<string>();
|
const renderedSourceEvents = new Set<string>();
|
||||||
const renderedToolIdentities = new Set<string>();
|
const renderedToolIdentities = new Set<string>();
|
||||||
@@ -47,7 +49,12 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isTerminalAssistantTraceEvent(event)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (isAssistantTraceEvent(event)) {
|
if (isAssistantTraceEvent(event)) {
|
||||||
@@ -55,7 +62,6 @@ export function traceDisplayRows(trace: Record<string, unknown> = {}, events: Tr
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isCompletionTraceEvent(event)) {
|
if (isCompletionTraceEvent(event)) {
|
||||||
const finalResponseText = traceFinalResponseText(effectiveTrace);
|
|
||||||
rows.push(finalResponseText ? traceFinalResponseRow(event, finalResponseText, options) : traceCompletionSummaryRow(effectiveTrace, event, options));
|
rows.push(finalResponseText ? traceFinalResponseRow(event, finalResponseText, options) : traceCompletionSummaryRow(effectiveTrace, event, options));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll";
|
|||||||
import { formatDisplayClock } from "@/config/runtime";
|
import { formatDisplayClock } from "@/config/runtime";
|
||||||
import { traceDisplayRows, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts";
|
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 listRef = ref<HTMLOListElement | null>(null);
|
||||||
const expanded = ref(false);
|
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 events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []);
|
||||||
const eventCount = computed(() => props.trace?.eventCount ?? events.value.length);
|
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 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 projectionDiagnosticLabel = computed(() => {
|
||||||
const projection = props.trace?.projection ?? null;
|
const projection = props.trace?.projection ?? null;
|
||||||
const health = projection?.projectionHealth ?? props.trace?.projectionHealth;
|
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);
|
return row.rowId.startsWith("tool:") || /commandExecution/u.test(row.header);
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterReadableRows(rows: TraceEventRow[], sealedFinalText: string | undefined): TraceEventRow[] {
|
function filterReadableRows(rows: TraceEventRow[], hideSealedFinalResponse: boolean): TraceEventRow[] {
|
||||||
const finalText = normalizeComparableText(sealedFinalText);
|
if (!hideSealedFinalResponse) return rows;
|
||||||
if (!finalText || finalText.length < 24) return rows;
|
return rows.filter((row) => !traceRowDuplicatesSealedFinal(row));
|
||||||
return rows.filter((row) => !assistantRowDuplicatesFinal(row, finalText));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function assistantRowDuplicatesFinal(row: TraceEventRow, finalText: string): boolean {
|
function traceRowDuplicatesSealedFinal(row: TraceEventRow): boolean {
|
||||||
if (rowIsTool(row)) return false;
|
if (rowIsTool(row)) return false;
|
||||||
const rowTextRaw = [row.header, row.preview, row.body].filter(Boolean).join("\n");
|
if (row.rowId.startsWith("trace-final-response:")) return true;
|
||||||
if (!/(?:助手消息|assistant\s+message|assistant)/iu.test(rowTextRaw)) return false;
|
return row.terminal === true && /(?:助手最终消息|assistant\s+(?:final\s+)?message)/iu.test(String(row.header ?? ""));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolCommandPreview(row: TraceEventRow): string | null {
|
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 {
|
function sealedDisplayDurationMs(message: ChatMessage, durationMs: number | null): number | null {
|
||||||
const key = messageDurationFloorKey(message);
|
const key = messageDurationFloorKey(message);
|
||||||
|
const runningFloor = displayedDurationFloorMs.get(key);
|
||||||
|
const sealedFloor = sealedDurationMs.get(key);
|
||||||
|
const previous = maxDefinedDurationMs(runningFloor, sealedFloor);
|
||||||
displayedDurationFloorMs.delete(key);
|
displayedDurationFloorMs.delete(key);
|
||||||
const previous = sealedDurationMs.get(key);
|
|
||||||
if (durationMs === null) return previous ?? null;
|
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);
|
if (next !== previous) sealedDurationMs.set(key, next);
|
||||||
return 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 {
|
function messageDurationFloorKey(message: ChatMessage): string {
|
||||||
return firstNonEmptyString(message.id, message.runnerTrace?.traceId, message.traceId) ?? "unknown-agent-message";
|
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>
|
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(message)">!</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 />
|
<LoadingState v-if="isAwaitingAgentBody(message)" class="message-loading" label="思考中..." compact />
|
||||||
<MessageMarkdown v-if="showMessageText(message)" class="message-text" :source="visibleMessageText(message)" />
|
<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 />
|
<ApiErrorDiagnostic v-if="messageHasDiagnostic(message)" class="message-diagnostic projection-diagnostic" :error="messageDiagnosticText(message)" :api-error="messageApiError(message)" :diagnostic="messageErrorDiagnostic(message)" compact />
|
||||||
|
|||||||
Reference in New Issue
Block a user