diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 6684e0c8..f3162950 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -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 = ``; let state = createScenarioState("baseline"); const sseClients = new Set(); @@ -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("", ` ${runtimeConfigScript}\n `); +} + function safeStaticPath(path: string): string | null { const normalized = resolve(distDir, `.${decodeURIComponent(path === "/" ? "/index.html" : path)}`); return normalized === distDir || normalized.startsWith(`${distDir}${sep}`) ? normalized : null; diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index 11951ee1..5bc10d15 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -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(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[], { 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); diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 9c883ca5..ac67a294 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -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 { - + diff --git a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts index df2e3b03..ab439b05 100644 --- a/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts +++ b/web/hwlab-cloud-web/src/composables/useTraceSubscription.ts @@ -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, diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index a87e65e0..4e585690 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -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 }; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index a8a4118e..33b62cc0 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -1676,7 +1676,7 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage { const baseText = firstNonEmptyString(message.text, messageText((message as Record).content), messageText((message as Record).message)); const finalText = finalResponseText((message as Record).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 | null { for (const message of [...messages].reverse()) { if (message.role !== "agent") continue; @@ -1850,27 +1842,8 @@ function messageTimingPatch(value: unknown): Partial { } function messageTimingPatchForMerge(message: ChatMessage, value: unknown): Partial { - // 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); - 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 { @@ -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; } diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts index 7a11a843..f976f65d 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/event-replay.spec.ts @@ -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(); diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/message-timing.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/message-timing.spec.ts index 21d4aaa7..0495d0e0 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/message-timing.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/message-timing.spec.ts @@ -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();