fix(workbench): remove frontend trace timing compensation
This commit is contained in:
@@ -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 = `<script>\nwindow.HWLAB_CLOUD_WEB_CONFIG = {\n ...(window.HWLAB_CLOUD_WEB_CONFIG ?? {}),\n displayTime: window.HWLAB_CLOUD_WEB_CONFIG?.displayTime ?? { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" },\n workbench: {\n ...(window.HWLAB_CLOUD_WEB_CONFIG?.workbench ?? {}),\n traceTimeline: window.HWLAB_CLOUD_WEB_CONFIG?.workbench?.traceTimeline ?? { autoExpandRunning: false, autoCollapseTerminal: false }\n }\n};\n</script>`;
|
||||
|
||||
let state = createScenarioState("baseline");
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
@@ -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("</head>", ` ${runtimeConfigScript}\n </head>`);
|
||||
}
|
||||
|
||||
function safeStaticPath(path: string): string | null {
|
||||
const normalized = resolve(distDir, `.${decodeURIComponent(path === "/" ? "/index.html" : path)}`);
|
||||
return normalized === distDir || normalized.startsWith(`${distDir}${sep}`) ? normalized : null;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user