From 03942afc5c337a898601fc461b545850e9ac78b0 Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 20 Jun 2026 14:53:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9B=20workbench=20?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=A4=B4=20timing=20=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/server-workbench-http.test.ts | 2 +- internal/cloud/server-workbench-http.ts | 65 +++++---------- .../cloud/workbench-projection-writer.test.ts | 14 +++- internal/cloud/workbench-projection-writer.ts | 81 ++++++++++++++----- web/hwlab-cloud-web/src/api/workbench.ts | 69 ++++++++-------- .../workbench/ConversationPanel.vue | 44 ++++------ web/hwlab-cloud-web/src/stores/workbench.ts | 67 ++++++++------- web/hwlab-cloud-web/src/styles/workbench.css | 22 +++-- web/hwlab-cloud-web/src/types/index.ts | 2 + .../real-captures/d601-v03-redacted.json | 4 +- .../tests/workbench-e2e/fixtures/selectors.ts | 2 + .../specs/message-timing.spec.ts | 23 +++--- 12 files changed, 213 insertions(+), 182 deletions(-) diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index 215e4549..b5a522a9 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -1567,7 +1567,7 @@ function testTimingProjection({ session, events = [], status, terminal }) { const lastEvent = events.at(-1) ?? null; const lastEventAt = timestampIso(lastEvent?.updatedAt ?? lastEvent?.createdAt ?? lastEvent?.occurredAt ?? session.updatedAt); const finishedAt = terminal ? timestampIso(session.endedAt ?? lastEventAt ?? session.updatedAt) : null; - const durationMs = elapsedMs(startedAt, finishedAt ?? lastEventAt); + const durationMs = terminal ? elapsedMs(startedAt, finishedAt ?? lastEventAt) : null; return { startedAt, lastEventAt, finishedAt, durationMs, status, valuesRedacted: true }; } diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 65ebf13b..ac7ba0d0 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -439,7 +439,8 @@ function factSessionSummary(session, facts = {}) { const checkpoint = traceId ? factCheckpointForTrace(facts, traceId) : null; const messages = factMessagesForSession(session, facts); const status = normalizeStatus(turn?.status ?? session?.status); - const timing = factTimingProjection({ turn, checkpoint, session, status }); + const timingSource = checkpoint ?? (traceId ? null : session); + const timing = factTimingProjection(timingSource, status); return { sessionId, threadId: safeOpaqueId(session?.threadId) ?? (textValue(session?.threadId) || null), @@ -520,14 +521,7 @@ function factMessageDto(message, parts = [], facts = {}) { const traceId = safeTraceId(message?.traceId) ?? null; const role = textValue(message?.role) || "agent"; const status = normalizeStatus(message?.status); - const timing = isAssistantLikeRole(role) - ? factTimingProjection({ - message, - turn: traceId ? factTurnForTrace(facts, traceId, message?.turnId) : null, - checkpoint: traceId ? factCheckpointForTrace(facts, traceId) : null, - status - }) - : factTimingProjection({ message, status }); + const timing = factTimingProjection(message, status); const text = projectionText(message?.text, message?.content, message?.message, message?.finalResponse); const normalizedParts = parts.length > 0 ? [...parts].sort(compareFactPartsAsc).map((part) => factPartDto(part, messageId, traceId)).filter(Boolean) @@ -591,7 +585,7 @@ function factTurnSnapshot({ turn = null, session = null, facts = {}, traceId, tu const checkpoint = safeTrace ? factCheckpointForTrace(facts, safeTrace) : null; const finalText = projectionText(turn?.finalResponse, turn?.assistantText, turn?.text, assistantMessage?.text); const trace = factTraceSnapshot(facts, safeTrace); - const timing = factTimingProjection({ turn, checkpoint, trace, session, status }); + const timing = factTimingProjection(checkpoint, status); return { turnId: resolvedTurnId, traceId: safeTrace, @@ -644,7 +638,7 @@ function factTraceSnapshot(facts = {}, traceId) { const status = durableTraceStatus(events); const lastEvent = events.at(-1) ?? null; const checkpoint = factCheckpointForTrace(facts, safeTrace); - const timing = factTimingProjection({ trace: { traceId: safeTrace, status, events, lastEvent, updatedAt: lastEvent?.updatedAt ?? lastEvent?.createdAt ?? checkpoint?.updatedAt ?? null }, checkpoint, status }); + const timing = factTimingProjection(checkpoint, status); return { traceId: safeTrace, status, @@ -699,7 +693,7 @@ function factProjectionForTrace(facts = {}, traceId) { const projectionStatus = normalizeProjectionStatus(checkpoint.projectionStatus); const projectionHealth = normalizeProjectionHealth(checkpoint.projectionHealth, projectionStatus); const diagnostic = objectValue(checkpoint.diagnostic); - const timing = factTimingProjection({ checkpoint }); + const timing = factTimingProjection(checkpoint, normalizeStatus(checkpoint.status)); return { projectionStatus, projectionHealth, @@ -718,39 +712,24 @@ function factProjectionForTrace(facts = {}, traceId) { }; } -function factTimingProjection({ message = null, turn = null, checkpoint = null, trace = null, session = null, status = null } = {}) { - const records = [message, turn, checkpoint, trace, session].filter(Boolean); - const directTiming = records.map((record) => objectValue(record?.timing)).find(Boolean) ?? null; - const direct = { - status, - timing: directTiming, - startedAt: firstFactTimestamp(records, "startedAt"), - lastEventAt: firstFactTimestamp(records, "lastEventAt"), - finishedAt: firstFactTimestamp(records, "finishedAt"), - durationMs: firstFactDuration(records), - updatedAt: firstFactTimestamp(records, "updatedAt") - }; - return createWorkbenchTurnTimingProjection({ result: direct, trace, session, status }); +function factTimingProjection(record = null, status = null) { + const source = objectValue(record?.timing); + const observedAt = new Date().toISOString(); + const startedAt = timestampIso(source?.startedAt); + const lastEventAt = timestampIso(source?.lastEventAt); + const normalizedStatus = normalizeStatus(status ?? record?.status); + const terminal = record?.terminal === true || TERMINAL_STATUSES.has(normalizedStatus) && !RUNNING_STATUSES.has(normalizedStatus); + const finishedAt = terminal ? timestampIso(source?.finishedAt) : null; + const durationMs = elapsedFactMs(startedAt, terminal ? finishedAt : observedAt); + const lastEventAgeMs = terminal ? null : elapsedFactMs(lastEventAt, observedAt); + return { startedAt, lastEventAt, finishedAt, durationMs, observedAt: terminal ? null : observedAt, lastEventAgeMs, valuesRedacted: source?.valuesRedacted !== false }; } -function firstFactTimestamp(records, key) { - for (const record of records) { - const nested = objectValue(record?.timing); - const timestamp = timestampIso(record?.[key] ?? nested?.[key]); - if (timestamp) return timestamp; - } - return null; -} - -function firstFactDuration(records) { - for (const record of records) { - const nested = objectValue(record?.timing); - for (const value of [record?.durationMs, nested?.durationMs]) { - const number = Number(value); - if (Number.isFinite(number) && number >= 0) return Math.trunc(number); - } - } - return null; +function elapsedFactMs(startedAt, endedAt) { + const start = Date.parse(String(startedAt ?? "")); + const end = Date.parse(String(endedAt ?? "")); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null; + return Math.trunc(end - start); } function timestampIso(value) { diff --git a/internal/cloud/workbench-projection-writer.test.ts b/internal/cloud/workbench-projection-writer.test.ts index e7aad81c..23f50808 100644 --- a/internal/cloud/workbench-projection-writer.test.ts +++ b/internal/cloud/workbench-projection-writer.test.ts @@ -96,6 +96,9 @@ test("workbench projection event commit writes trace event and checkpoint facts" allocateWorkbenchProjectedSeq(params, requestMeta) { return baseStore.allocateWorkbenchProjectedSeq(params, requestMeta); }, + queryWorkbenchFacts(params) { + return baseStore.queryWorkbenchFacts(params); + }, async writeWorkbenchFacts(params, requestMeta) { const result = baseStore.writeWorkbenchFacts(params, requestMeta); factWrites.push({ params, requestMeta }); @@ -116,6 +119,7 @@ test("workbench projection event commit writes trace event and checkpoint facts" runId: "run_writer_event", commandId: "cmd_writer_event", startedAt: "2026-06-20T11:00:30.000Z", + durationMs: 3000, createdAt: "2026-06-20T11:01:00.000Z" } }); @@ -132,7 +136,6 @@ test("workbench projection event commit writes trace event and checkpoint facts" terminal: true, runId: "run_writer_event", commandId: "cmd_writer_event", - startedAt: "2026-06-20T11:00:30.000Z", createdAt: "2026-06-20T11:02:00.000Z" } }); @@ -140,6 +143,10 @@ test("workbench projection event commit writes trace event and checkpoint facts" assert.equal(factWrites.length, 2); assert.equal(factWrites[0].params.facts.traceEvents[0].projectedSeq, 1); assert.equal(factWrites[0].params.facts.checkpoints[0].projectionStatus, "projecting"); + assert.equal(factWrites[0].params.facts.checkpoints[0].startedAt, "2026-06-20T11:00:30.000Z"); + assert.equal(factWrites[0].params.facts.checkpoints[0].lastEventAt, "2026-06-20T11:01:00.000Z"); + assert.equal(factWrites[0].params.facts.checkpoints[0].durationMs, null); + assert.equal(factWrites[0].params.facts.checkpoints[0].timing.durationMs, null); assert.equal(factWrites[1].params.facts.sessions[0].status, "completed"); assert.equal(factWrites[1].params.facts.traceEvents[0].sealed, true); assert.equal(factWrites[1].params.facts.traceEvents[0].projectedSeq, 2); @@ -173,6 +180,8 @@ test("workbench projection event writer allocates projectedSeq idempotently by s seq: 1, sourceSeq: 71, label: "agentrun:backend:runner-ready", + startedAt: null, + durationMs: 999, createdAt: "2026-06-20T12:00:02.000Z" } }); @@ -182,6 +191,7 @@ test("workbench projection event writer allocates projectedSeq idempotently by s assert.deepEqual(loaded.facts.traceEvents.map((event) => event.projectedSeq), [1, 2]); assert.deepEqual(loaded.facts.traceEvents.map((event) => event.sourceSeq), [70, 71]); assert.equal(loaded.facts.checkpoints[0].projectedSeq, 2); + assert.equal(loaded.facts.checkpoints[0].startedAt, "2026-06-20T12:00:01.000Z"); assert.equal(loaded.facts.checkpoints[0].lastEventAt, "2026-06-20T12:00:02.000Z"); - assert.equal(loaded.facts.checkpoints[0].durationMs, 1000); + assert.equal(loaded.facts.checkpoints[0].durationMs, null); }); diff --git a/internal/cloud/workbench-projection-writer.ts b/internal/cloud/workbench-projection-writer.ts index 8aae5b08..d1d08007 100644 --- a/internal/cloud/workbench-projection-writer.ts +++ b/internal/cloud/workbench-projection-writer.ts @@ -6,7 +6,7 @@ import { createHash } from "node:crypto"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { safeTraceId } from "./server-http-utils.ts"; -import { createWorkbenchTurnProjection, createWorkbenchTurnTimingProjection, normalizeWorkbenchStatus, projectionDiagnostics, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; +import { createWorkbenchTurnProjection, normalizeWorkbenchStatus, projectionDiagnostics, TERMINAL_STATUSES } from "./workbench-turn-projection.ts"; export async function writeWorkbenchProjectionSession({ accessController, runtimeStore = null, traceStore = defaultCodeAgentTraceStore, traceId, ownerUserId, ownerRole = null, sessionId = null, projectId = null, conversationId = null, threadId = null, status = "active", session = {}, payload = null, params = {}, preserveLastTraceId = false } = {}) { if (!ownerUserId || typeof accessController?.recordAgentSessionOwner !== "function") return null; @@ -63,21 +63,12 @@ export async function writeWorkbenchProjectionEvent({ runtimeStore = null, event const turnId = textValue(event.turnId) || traceId; const terminal = event.terminal === true || TERMINAL_STATUSES.has(normalizeWorkbenchStatus(event.status)); const status = terminal ? normalizeWorkbenchStatus(event.status) : "running"; - const explicitStartedAt = optionalTimestampValue(event.startedAt ?? event.traceStartedAt ?? event.runnerStartedAt ?? event.agentRun?.startedAt ?? (sourceSeq <= 1 ? occurredAt : null)); - const timing = createWorkbenchTurnTimingProjection({ - result: { ...event, status, updatedAt: occurredAt, startedAt: explicitStartedAt, finishedAt: terminal ? occurredAt : event.finishedAt }, - trace: { - traceId, - status, - startedAt: explicitStartedAt, - lastEventAt: occurredAt, - finishedAt: terminal ? occurredAt : null, - elapsedMs: event.durationMs ?? event.elapsedMs, - updatedAt: occurredAt, - lastEvent: { ...event, createdAt: occurredAt, updatedAt: timestampValue(event.updatedAt ?? occurredAt) } - }, - terminal - }); + const previousCheckpoint = await latestWorkbenchCheckpoint(runtimeStore, traceId); + const previousTiming = normalizeTimingProjection(previousCheckpoint?.timing) ?? normalizeTimingProjection(previousCheckpoint); + const explicitStartedAt = optionalTimestampValue(event.startedAt ?? event.traceStartedAt ?? event.runnerStartedAt ?? event.agentRun?.startedAt); + const startedAt = explicitStartedAt ?? previousTiming?.startedAt ?? (sourceSeq <= 1 ? occurredAt : null); + const finishedAt = terminal ? optionalTimestampValue(event.finishedAt) ?? occurredAt : null; + const timing = eventTimingProjection({ startedAt, lastEventAt: occurredAt, finishedAt, terminal }); const sourceEventId = workbenchSourceEventId(event, { traceId, sourceSeq, occurredAt }); const allocation = await runtimeStore.allocateWorkbenchProjectedSeq({ traceId, @@ -207,7 +198,7 @@ function buildWorkbenchProjectionFacts({ traceId = null, ownerUserId = null, own const normalizedStatus = normalizeWorkbenchStatus(payload?.status ?? session?.sessionStatus ?? status); const terminal = TERMINAL_STATUSES.has(normalizedStatus); const projection = createWorkbenchTurnProjection({ traceId: safeId, result: payload, session: { id: resolvedSessionId, status: normalizedStatus, session }, trace: payload?.runnerTrace ?? null }); - const timing = projection.timing; + const timing = projectionTimingForStatus(projection.timing, terminal); const diagnostic = projectionDiagnostics({ traceId: safeId, result: payload, trace: payload?.runnerTrace ?? null, projection }); const messages = normalizeMessages(session?.messages, { traceId: safeId, @@ -315,7 +306,7 @@ function normalizeMessageFact(message = {}, index, { traceId, sessionId, convers const role = textValue(message.role) || "agent"; const status = normalizeWorkbenchStatus(message.status ?? (terminal && role !== "user" ? "completed" : role === "user" ? "sent" : "running")); const messageTerminal = role !== "user" && TERMINAL_STATUSES.has(status); - const timing = role === "user" ? normalizeTimingProjection(message.timing ?? message) : normalizeTimingProjection(message.timing ?? message, contextTiming); + const timing = role === "user" ? normalizeTimingProjection(message.timing) ?? emptyTimingProjection() : normalizeTimingProjection(contextTiming) ?? emptyTimingProjection(); return { ...message, messageId, @@ -343,10 +334,56 @@ function normalizeMessageFact(message = {}, index, { traceId, sessionId, convers }; } -function normalizeTimingProjection(...values) { - const direct = values.find((value) => value && typeof value === "object" && (value.startedAt || value.lastEventAt || value.finishedAt || Number.isFinite(Number(value.durationMs)))) ?? null; - const nested = values.map((value) => value?.timing).find((value) => value && typeof value === "object") ?? null; - return createWorkbenchTurnTimingProjection({ result: { ...(nested ?? {}), ...(direct ?? {}), timing: nested ?? null } }); +async function latestWorkbenchCheckpoint(runtimeStore, traceId) { + if (typeof runtimeStore?.queryWorkbenchFacts !== "function") return null; + const result = await runtimeStore.queryWorkbenchFacts({ traceId, families: ["checkpoints"], limit: 1 }); + return Array.isArray(result?.facts?.checkpoints) ? result.facts.checkpoints[0] ?? null : null; +} + +function projectionTimingForStatus(value, terminal) { + const timing = normalizeTimingProjection(value) ?? emptyTimingProjection(); + if (terminal) return timing; + return { ...timing, finishedAt: null, durationMs: null, valuesRedacted: timing.valuesRedacted !== false }; +} + +function eventTimingProjection({ startedAt = null, lastEventAt = null, finishedAt = null, terminal = false } = {}) { + const normalizedStartedAt = optionalTimestampValue(startedAt); + const normalizedLastEventAt = optionalTimestampValue(lastEventAt); + const normalizedFinishedAt = terminal ? optionalTimestampValue(finishedAt ?? normalizedLastEventAt) : null; + return { + startedAt: normalizedStartedAt, + lastEventAt: normalizedLastEventAt, + finishedAt: normalizedFinishedAt, + durationMs: terminal ? elapsedBetween(normalizedStartedAt, normalizedFinishedAt ?? normalizedLastEventAt) : null, + valuesRedacted: true + }; +} + +function normalizeTimingProjection(value) { + const source = value && typeof value === "object" ? value : null; + if (!source) return null; + const startedAt = optionalTimestampValue(source.startedAt); + const lastEventAt = optionalTimestampValue(source.lastEventAt); + const finishedAt = optionalTimestampValue(source.finishedAt); + const durationMs = durationValue(source.durationMs); + if (!startedAt && !lastEventAt && !finishedAt && durationMs === null) return null; + return { ...source, startedAt, lastEventAt, finishedAt, durationMs, valuesRedacted: source.valuesRedacted !== false }; +} + +function emptyTimingProjection() { + return { startedAt: null, lastEventAt: null, finishedAt: null, durationMs: null, valuesRedacted: true }; +} + +function durationValue(value) { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null; +} + +function elapsedBetween(startedAt, endedAt) { + const start = Date.parse(String(startedAt ?? "")); + const end = Date.parse(String(endedAt ?? "")); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null; + return Math.trunc(end - start); } function isEmptyFacts(facts) { diff --git a/web/hwlab-cloud-web/src/api/workbench.ts b/web/hwlab-cloud-web/src/api/workbench.ts index 019458e3..47d75aad 100644 --- a/web/hwlab-cloud-web/src/api/workbench.ts +++ b/web/hwlab-cloud-web/src/api/workbench.ts @@ -93,17 +93,17 @@ function normalizeWorkbenchTurnResult(result: ApiResult> const trace = recordValue(turn.trace); const status = normalizeStatus(textValue(turn.status)) ?? "unknown"; const projection = normalizeProjectionDiagnostic(result.data.projection, turn.projection, result.data); - const timing = normalizeTimingProjection(turn.timing, result.data.timing, turn, result.data); + const timing = normalizeTimingProjection(turn); return { ...result, data: { ...turn, projection, timing, - startedAt: timing?.startedAt ?? textValue(turn.startedAt, result.data.startedAt) ?? undefined, - lastEventAt: timing?.lastEventAt ?? textValue(turn.lastEventAt, result.data.lastEventAt) ?? undefined, - finishedAt: timing?.finishedAt ?? textValue(turn.finishedAt, result.data.finishedAt) ?? undefined, - durationMs: timing?.durationMs ?? numberValue(turn.durationMs ?? result.data.durationMs) ?? undefined, + startedAt: timing?.startedAt ?? undefined, + lastEventAt: timing?.lastEventAt ?? undefined, + finishedAt: timing?.finishedAt ?? undefined, + durationMs: timing?.durationMs ?? undefined, projectionStatus: projection?.projectionStatus ?? textValue(result.data.projectionStatus) ?? undefined, projectionHealth: projection?.projectionHealth ?? textValue(result.data.projectionHealth) ?? undefined, lastProjectedSeq: Number.isFinite(Number(result.data.lastProjectedSeq)) ? Number(result.data.lastProjectedSeq) : undefined, @@ -133,7 +133,7 @@ function normalizeWorkbenchTraceResult(result: ApiResult const hasMore = result.data.hasMore === true; const fullTraceLoaded = typeof result.data.fullTraceLoaded === "boolean" ? result.data.fullTraceLoaded : undefined; const projection = normalizeProjectionDiagnostic(result.data.projection, result.data); - const timing = normalizeTimingProjection(result.data.timing, result.data); + const timing = normalizeTimingProjection(result.data); return { ...result, data: { @@ -142,10 +142,10 @@ function normalizeWorkbenchTraceResult(result: ApiResult status: undefined, traceStatus, timing, - startedAt: timing?.startedAt ?? textValue(result.data.startedAt) ?? undefined, - lastEventAt: timing?.lastEventAt ?? textValue(result.data.lastEventAt) ?? undefined, - finishedAt: timing?.finishedAt ?? textValue(result.data.finishedAt) ?? undefined, - durationMs: timing?.durationMs ?? numberValue(result.data.durationMs) ?? undefined, + startedAt: timing?.startedAt ?? undefined, + lastEventAt: timing?.lastEventAt ?? undefined, + finishedAt: timing?.finishedAt ?? undefined, + durationMs: timing?.durationMs ?? undefined, events, traceEvents: events, eventCount, @@ -162,10 +162,10 @@ function normalizeWorkbenchTraceResult(result: ApiResult traceId: textValue(result.data.traceId) ?? traceId, status: undefined, timing, - startedAt: timing?.startedAt ?? textValue(result.data.startedAt) ?? undefined, - lastEventAt: timing?.lastEventAt ?? textValue(result.data.lastEventAt) ?? undefined, - finishedAt: timing?.finishedAt ?? textValue(result.data.finishedAt) ?? undefined, - durationMs: timing?.durationMs ?? numberValue(result.data.durationMs) ?? undefined, + startedAt: timing?.startedAt ?? undefined, + lastEventAt: timing?.lastEventAt ?? undefined, + finishedAt: timing?.finishedAt ?? undefined, + durationMs: timing?.durationMs ?? undefined, events, eventCount, hasMore, @@ -183,26 +183,27 @@ function normalizeWorkbenchTraceResult(result: ApiResult }; } -function normalizeTimingProjection(...values: unknown[]): WorkbenchTurnTimingProjection | undefined { - for (const value of values) { - const record = recordValue(value); - if (!record) continue; - const source = recordValue(record.timing) ?? record; - const startedAt = textValue(source.startedAt, record.startedAt); - const lastEventAt = textValue(source.lastEventAt, record.lastEventAt); - const finishedAt = textValue(source.finishedAt, record.finishedAt); - const durationMs = numberValue(source.durationMs ?? record.durationMs); - if (!startedAt && !lastEventAt && !finishedAt && durationMs == null) continue; - return { - ...source, - startedAt: startedAt ?? null, - lastEventAt: lastEventAt ?? null, - finishedAt: finishedAt ?? null, - durationMs, - valuesRedacted: source.valuesRedacted !== false - } as WorkbenchTurnTimingProjection; - } - return undefined; +function normalizeTimingProjection(value: unknown): WorkbenchTurnTimingProjection | undefined { + const record = recordValue(value); + const source = recordValue(record?.timing); + if (!source) return undefined; + const startedAt = textValue(source.startedAt); + const lastEventAt = textValue(source.lastEventAt); + const finishedAt = textValue(source.finishedAt); + const durationMs = numberValue(source.durationMs); + const observedAt = textValue(source.observedAt); + const lastEventAgeMs = numberValue(source.lastEventAgeMs); + if (!startedAt && !lastEventAt && !finishedAt && durationMs == null && lastEventAgeMs == null) return undefined; + return { + ...source, + startedAt: startedAt ?? null, + lastEventAt: lastEventAt ?? null, + finishedAt: finishedAt ?? null, + durationMs, + observedAt: observedAt ?? null, + lastEventAgeMs: lastEventAgeMs ?? null, + valuesRedacted: source.valuesRedacted !== false + } as WorkbenchTurnTimingProjection; } function normalizeStatus(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 6558af51..0068efa5 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -2,7 +2,7 @@ // SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0; PJ2026-0104010803 唯一投影 draft-2026-06-19-p2-terminal-sealed-final-response. // Confirms Workbench message visibility after Vue render and keeps sealed final responses separate from diagnostics. -import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; +import { computed, nextTick, onMounted, ref, watch } from "vue"; import type { ChatMessage } from "@/types"; import LoadingState from "@/components/common/LoadingState.vue"; import StatusBadge from "@/components/common/StatusBadge.vue"; @@ -24,7 +24,6 @@ const clipboard = useClipboard(); const panelRef = ref(null); const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useBottomFollowScroll(panelRef, { threshold: 56 }); const detailMessageId = ref(null); -const timingNowMs = ref(Date.now()); const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages); const detailMessage = computed(() => workbench.activeMessages.find((message) => message.id === detailMessageId.value) ?? null); const traceTimelinePolicy = computed(() => workbenchTraceTimelinePolicy()); @@ -36,7 +35,6 @@ const scrollSignature = computed(() => workbench.activeMessages.map((message) => message.runnerTrace?.eventCount ?? message.runnerTrace?.events?.length ?? 0, message.runnerTrace?.status ?? "" ].join(":"))); -let timingClock: number | null = null; watch(scrollSignature, async () => { await keepBottomAfterUpdate(); @@ -47,12 +45,6 @@ onMounted(async () => { await nextTick(); scrollToBottom(); acknowledgeVisibleMessages(); - if (typeof window !== "undefined") timingClock = window.setInterval(() => { timingNowMs.value = Date.now(); }, 1000); -}); - -onUnmounted(() => { - if (typeof window !== "undefined" && timingClock !== null) window.clearInterval(timingClock); - timingClock = null; }); watch(() => workbench.sessionDetailLoading, async () => { @@ -132,16 +124,20 @@ function traceForDisplay(message: ChatMessage): ChatMessage["runnerTrace"] { return { ...message.runnerTrace, status }; } -function messageTimingMeta(message: ChatMessage): { recent: string | null; duration: string | null; label: string } | null { +function messageDurationMeta(message: ChatMessage): { text: string; label: string } | null { if (message.role !== "agent") return null; - const timing = message.timing ?? null; - const lastEventAt = firstNonEmptyString(timing?.lastEventAt, message.lastEventAt); - const durationMs = finiteDurationMs(timing?.durationMs ?? message.durationMs); - const recent = isRunningMessage(message) && lastEventAt ? `最近 ${formatRelativeSince(lastEventAt, timingNowMs.value)}` : null; - const duration = durationMs !== null ? `耗时 ${formatDuration(durationMs)}` : null; - if (!recent && !duration) return null; - const label = [recent ? `最近事件:${recent.replace(/^最近\s*/u, "")}` : null, duration ? `总耗时:${duration.replace(/^耗时\s*/u, "")}` : null].filter(Boolean).join(";"); - return { recent, duration, label }; + const durationMs = finiteDurationMs(message.timing?.durationMs); + if (durationMs === null) return null; + const value = formatDuration(durationMs); + return { text: `耗时 ${value}`, label: `总耗时:${value}` }; +} + +function messageActivityMeta(message: ChatMessage): { text: string; label: string } | null { + if (!isRunningMessage(message)) return null; + const ageMs = finiteDurationMs(message.timing?.lastEventAgeMs); + if (ageMs === null) return null; + const value = formatDuration(ageMs) + "前"; + return { text: `最近 ${value}`, label: `最近事件:${value}` }; } function finiteDurationMs(value: unknown): number | null { @@ -149,12 +145,6 @@ function finiteDurationMs(value: unknown): number | null { return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null; } -function formatRelativeSince(iso: string, nowMsValue: number): string { - const observedMs = Date.parse(iso); - if (!Number.isFinite(observedMs)) return "未知"; - return formatDuration(Math.max(0, nowMsValue - observedMs)) + "前"; -} - function formatDuration(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); if (seconds < 60) return `${seconds} 秒`; @@ -174,12 +164,10 @@ function formatDuration(ms: number): string {
{{ message.title }} - - {{ messageTimingMeta(message)?.recent }} - {{ messageTimingMeta(message)?.duration }} - + {{ messageDurationMeta(message)?.text }}
+ {{ messageActivityMeta(message)?.text }} diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index c57ddb8e..79774bf1 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -439,7 +439,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const preservedTrace = authority ?? previous?.runnerTrace ?? null; if (!preservedTrace) return message; const runnerTrace = message.runnerTrace ? mergeRunnerTrace(message.runnerTrace, preservedTrace) : preservedTrace; - return { ...message, ...messageTimingPatch(message, runnerTrace, previous), runnerTrace }; + return { ...message, ...messageTimingPatch(message), runnerTrace }; } function findExistingProjectionMessage(message: ChatMessage, existing: ChatMessage[]): ChatMessage | null { @@ -506,7 +506,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const error = normalizeAgentError((result as AgentChatResultResponse).error ?? runnerTrace?.error ?? message.error); const agentRun = agentRunFromResult(result as AgentChatResultResponse, runnerTrace) ?? agentRunFromMessage(message); const projection = projectionFromResult(result as AgentChatResultResponse) ?? runnerTrace.projection ?? message.projection ?? null; - return { ...message, ...messageTimingPatch(result, runnerTrace, message), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; + return { ...message, ...messageTimingPatch(result), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); } @@ -651,7 +651,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const authority = traceId ? traceAuthorityById.value[traceId] ?? null : null; if (!authority) return message; const runnerTrace = mergeRunnerTrace(message.runnerTrace, authority); - return { ...message, ...messageTimingPatch(message, runnerTrace), runnerTrace }; + return { ...message, ...messageTimingPatch(message), runnerTrace }; }); } @@ -701,7 +701,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { projectionHealth: result.projectionHealth ?? result.projection?.projectionHealth, staleMs: result.staleMs ?? result.projection?.staleMs, blocker: result.blocker ?? result.projection?.blocker, - ...messageTimingPatch(result, result.runnerTrace), + ...messageTimingPatch(result), lastEventLabel: result.lastEventLabel ?? events.at(-1)?.label ?? events.at(-1)?.type, updatedAt: new Date().toISOString() } as NonNullable; @@ -710,7 +710,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const projection = projectionFromResult(result) ?? runnerTrace.projection ?? message.projection ?? null; const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); rememberTraceAuthority(runnerTrace); - return { ...message, ...messageTimingPatch(result, runnerTrace, message), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; + return { ...message, ...messageTimingPatch(result), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; })); markWorkbenchTraceProjected(traceId); } @@ -1014,7 +1014,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { rememberTraceAuthority(runnerTrace); const error = message.role === "agent" ? normalizeAgentError(runnerTrace.error ?? message.error) : normalizeAgentError(message.error); const projection = trace.projection ?? runnerTrace.projection ?? message.projection ?? null; - return { ...message, ...messageTimingPatch(trace, runnerTrace, message), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, updatedAt: new Date().toISOString() }; + return { ...message, ...messageTimingPatch(trace), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, updatedAt: new Date().toISOString() }; })); markWorkbenchTraceProjected(traceId); void refreshSessions(ownerSessionId); @@ -1032,7 +1032,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); const projection = projectionFromResult(result) ?? runnerTrace.projection ?? message.projection ?? null; const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); - return { ...message, ...messageTimingPatch(result, runnerTrace, message), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; + return { ...message, ...messageTimingPatch(result), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; })); rememberTurnStatus(traceId, result); markWorkbenchTraceProjected(traceId); @@ -1071,7 +1071,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); const projection = projectionFromResult(result) ?? runnerTrace.projection ?? message.projection ?? null; - return { ...message, ...messageTimingPatch(result, runnerTrace, message), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; + return { ...message, ...messageTimingPatch(result), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; })); void hydrateTraceEvents(serverState.value.messagesBySessionId[ownerSessionId] ?? []); void refreshSessions(ownerSessionId); @@ -1292,7 +1292,7 @@ function normalizeChatMessage(message: ChatMessage): ChatMessage { : "" : firstNonEmptyString(baseText, finalText, errorText) ?? ""; const messageId = firstNonEmptyString((message as Record).messageId, message.id) ?? nextProtocolId("msg"); - return { ...message, ...messageTimingPatch(message, runnerTrace), 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 }; + 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 activeTraceIdFromMessages(messages: ChatMessage[], turnStatusAuthority: Record): string | null { @@ -1364,7 +1364,7 @@ function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: const events = firstArray(result.events, result.traceEvents, resultTrace?.events, previous?.events); const agentRun = asAgentRun(result.agentRun ?? resultTrace?.agentRun ?? previous?.agentRun); const traceDetailStatus = firstNonEmptyString(result.traceStatus, resultTrace?.traceStatus, resultTrace?.status); - const timing = normalizeTimingProjection(result, resultTrace, previous); + const timing = normalizeTimingProjection(result); const nextTrace = { ...resultTrace, traceId: firstNonEmptyString(result.traceId, resultTrace?.traceId, previous?.traceId) ?? undefined, @@ -1384,10 +1384,10 @@ function mergeTerminalResultTrace(previous: ChatMessage["runnerTrace"], result: staleMs: result.staleMs ?? resultTrace?.staleMs ?? previous?.staleMs ?? null, blocker: result.blocker ?? resultTrace?.blocker ?? previous?.blocker ?? null, timing, - startedAt: timing?.startedAt ?? result.startedAt ?? resultTrace?.startedAt ?? previous?.startedAt ?? null, - lastEventAt: timing?.lastEventAt ?? result.lastEventAt ?? resultTrace?.lastEventAt ?? previous?.lastEventAt ?? null, - finishedAt: timing?.finishedAt ?? result.finishedAt ?? resultTrace?.finishedAt ?? previous?.finishedAt ?? null, - durationMs: timing?.durationMs ?? result.durationMs ?? resultTrace?.durationMs ?? previous?.durationMs ?? null, + startedAt: timing?.startedAt ?? null, + lastEventAt: timing?.lastEventAt ?? null, + finishedAt: timing?.finishedAt ?? null, + durationMs: timing?.durationMs ?? null, runnerKind: firstNonEmptyString(agentRun?.adapter, resultTrace?.runnerKind, previous?.runnerKind) ?? undefined, sessionMode: firstNonEmptyString(agentRun?.backendProfile, resultTrace?.sessionMode, previous?.sessionMode) ?? undefined, lastEventLabel: firstNonEmptyString(result.lastEventLabel, resultTrace?.lastEventLabel, previous?.lastEventLabel, events.at(-1)?.label, events.at(-1)?.type) ?? undefined, @@ -1404,7 +1404,7 @@ function normalizeMessageRunnerTrace(message: ChatMessage): ChatMessage["runnerT const agentRun = agentRunFromMessage(message); const error = normalizeAgentError(message.error ?? trace?.error); const projection = projectionFromMessage(message, trace as ChatMessage["runnerTrace"] | null); - const timing = normalizeTimingProjection(message, trace); + const timing = normalizeTimingProjection(message); if (!trace && !agentRun && !error && !projection) return message.runnerTrace ?? null; return { ...(trace ?? {}), @@ -1419,32 +1419,31 @@ function normalizeMessageRunnerTrace(message: ChatMessage): ChatMessage["runnerT staleMs: projection?.staleMs ?? trace?.staleMs, blocker: projection?.blocker ?? trace?.blocker, timing, - startedAt: timing?.startedAt ?? message.startedAt ?? trace?.startedAt, - lastEventAt: timing?.lastEventAt ?? message.lastEventAt ?? trace?.lastEventAt, - finishedAt: timing?.finishedAt ?? message.finishedAt ?? trace?.finishedAt, - durationMs: timing?.durationMs ?? message.durationMs ?? trace?.durationMs, + startedAt: timing?.startedAt ?? null, + lastEventAt: timing?.lastEventAt ?? null, + finishedAt: timing?.finishedAt ?? null, + durationMs: timing?.durationMs ?? null, runnerKind: agentRun?.adapter ?? trace?.runnerKind, sessionMode: agentRun?.backendProfile ?? trace?.sessionMode } as ChatMessage["runnerTrace"]; } -function normalizeTimingProjection(...values: unknown[]): WorkbenchTurnTimingProjection | null { - for (const value of values) { - const record = recordValue(value); - if (!record) continue; - const source = recordValue(record.timing) ?? record; - const startedAt = firstNonEmptyString(source.startedAt, record.startedAt); - const lastEventAt = firstNonEmptyString(source.lastEventAt, record.lastEventAt); - const finishedAt = firstNonEmptyString(source.finishedAt, record.finishedAt); - const durationMs = firstFiniteNumber(source.durationMs, record.durationMs); - if (!startedAt && !lastEventAt && !finishedAt && durationMs == null) continue; - return { ...source, startedAt: startedAt ?? null, lastEventAt: lastEventAt ?? null, finishedAt: finishedAt ?? null, durationMs: durationMs ?? null, valuesRedacted: source.valuesRedacted !== false } as WorkbenchTurnTimingProjection; - } - return null; +function normalizeTimingProjection(value: unknown): WorkbenchTurnTimingProjection | null { + const record = recordValue(value); + const source = recordValue(record?.timing); + if (!source) return null; + const startedAt = firstNonEmptyString(source.startedAt); + const lastEventAt = firstNonEmptyString(source.lastEventAt); + const finishedAt = firstNonEmptyString(source.finishedAt); + const durationMs = firstFiniteNumber(source.durationMs); + const observedAt = firstNonEmptyString(source.observedAt); + const lastEventAgeMs = firstFiniteNumber(source.lastEventAgeMs); + if (!startedAt && !lastEventAt && !finishedAt && durationMs == null && lastEventAgeMs == null) return null; + return { ...source, startedAt: startedAt ?? null, lastEventAt: lastEventAt ?? null, finishedAt: finishedAt ?? null, durationMs: durationMs ?? null, observedAt: observedAt ?? null, lastEventAgeMs: lastEventAgeMs ?? null, valuesRedacted: source.valuesRedacted !== false } as WorkbenchTurnTimingProjection; } -function messageTimingPatch(...values: unknown[]): Partial { - const timing = normalizeTimingProjection(...values); +function messageTimingPatch(value: unknown): Partial { + const timing = normalizeTimingProjection(value); if (!timing) return {}; return { timing, startedAt: timing.startedAt ?? null, lastEventAt: timing.lastEventAt ?? null, finishedAt: timing.finishedAt ?? null, durationMs: timing.durationMs ?? null }; } diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index afe70343..7d7a5505 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -887,12 +887,9 @@ .message-timing-meta { display: inline-flex; - min-width: 0; - max-width: 100%; - flex: 0 1 auto; - flex-wrap: wrap; + min-width: 64px; + flex: 0 0 auto; align-items: center; - gap: 4px 8px; color: #64748b; font-size: 12px; font-variant-numeric: tabular-nums; @@ -900,10 +897,23 @@ line-height: 1.25; } -.message-timing-meta span { +.message-duration-meta, +.message-activity-meta { white-space: nowrap; } +.message-activity-meta { + display: inline-flex; + min-width: 86px; + flex: 0 0 auto; + justify-content: flex-end; + color: #475569; + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: 650; + line-height: 1.25; +} + .message-header-actions { display: inline-flex; flex: 0 0 auto; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index c0ac57c8..1676cf9a 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -123,6 +123,8 @@ export interface WorkbenchTurnTimingProjection { lastEventAt?: string | null; finishedAt?: string | null; durationMs?: number | null; + observedAt?: string | null; + lastEventAgeMs?: number | null; valuesRedacted?: boolean; [key: string]: unknown; } diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json index da55d555..ac6fe52a 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json +++ b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json @@ -41,7 +41,7 @@ "firstUserMessagePreview": "运行中的板级验证", "messages": [ { "id": "msg_running_user", "messageId": "msg_running_user", "role": "user", "title": "用户", "text": "运行中的板级验证", "status": "sent", "createdAt": "2026-06-17T09:39:40.000Z", "sessionId": "ses_running", "threadId": "thr_running", "turnId": "turn_running" }, - { "id": "msg_running_agent", "messageId": "msg_running_agent", "role": "agent", "title": "Code Agent", "text": "", "status": "running", "createdAt": "2026-06-17T09:39:41.000Z", "sessionId": "ses_running", "threadId": "thr_running", "traceId": "trc_running", "turnId": "turn_running", "timing": { "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 4000, "valuesRedacted": true }, "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 4000, "runnerTrace": { "traceId": "trc_running", "status": "running", "sessionId": "ses_running", "threadId": "thr_running", "timing": { "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 4000, "valuesRedacted": true }, "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 4000, "events": [{ "seq": 1, "createdAt": "2026-06-17T09:39:42.000Z", "label": "agentrun:assistant:message", "type": "assistant_message", "status": "running", "message": "正在检查测试工程。" }], "eventCount": 1, "fullTraceLoaded": false, "hasMore": false } } + { "id": "msg_running_agent", "messageId": "msg_running_agent", "role": "agent", "title": "Code Agent", "text": "", "status": "running", "createdAt": "2026-06-17T09:39:41.000Z", "sessionId": "ses_running", "threadId": "thr_running", "traceId": "trc_running", "turnId": "turn_running", "timing": { "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 1180000, "valuesRedacted": true, "observedAt": "2026-06-17T09:59:21.000Z", "lastEventAgeMs": 1176000 }, "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 1180000, "runnerTrace": { "traceId": "trc_running", "status": "running", "sessionId": "ses_running", "threadId": "thr_running", "timing": { "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 1180000, "valuesRedacted": true, "observedAt": "2026-06-17T09:59:21.000Z", "lastEventAgeMs": 1176000 }, "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 1180000, "events": [{ "seq": 1, "createdAt": "2026-06-17T09:39:42.000Z", "label": "agentrun:assistant:message", "type": "assistant_message", "status": "running", "message": "正在检查测试工程。" }], "eventCount": 1, "fullTraceLoaded": false, "hasMore": false } } ] }, { @@ -85,7 +85,7 @@ } ], "traces": { - "trc_running": { "traceId": "trc_running", "status": "running", "sessionId": "ses_running", "threadId": "thr_running", "turnId": "turn_running", "timing": { "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 4000, "valuesRedacted": true }, "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 4000, "events": [{ "seq": 1, "createdAt": "2026-06-17T09:39:42.000Z", "label": "agentrun:assistant:message", "type": "assistant_message", "status": "running", "message": "正在检查测试工程。" }, { "seq": 2, "createdAt": "2026-06-17T09:39:45.000Z", "label": "agentrun:tool:commandExecution:started", "type": "tool_call", "status": "running", "itemId": "tool_running_check", "toolName": "commandExecution", "command": "bun run check:tsc" }], "eventCount": 2, "fullTraceLoaded": false, "hasMore": false, "agentRun": { "adapter": "agentrun-v02", "runId": "run_running", "commandId": "cmd_running", "runnerId": "runner_d601", "namespace": "hwlab-v03", "backendProfile": "codex-api", "status": "running", "valuesPrinted": false } }, + "trc_running": { "traceId": "trc_running", "status": "running", "sessionId": "ses_running", "threadId": "thr_running", "turnId": "turn_running", "timing": { "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 1180000, "valuesRedacted": true, "observedAt": "2026-06-17T09:59:21.000Z", "lastEventAgeMs": 1176000 }, "startedAt": "2026-06-17T09:39:41.000Z", "lastEventAt": "2026-06-17T09:39:45.000Z", "finishedAt": null, "durationMs": 1180000, "events": [{ "seq": 1, "createdAt": "2026-06-17T09:39:42.000Z", "label": "agentrun:assistant:message", "type": "assistant_message", "status": "running", "message": "正在检查测试工程。" }, { "seq": 2, "createdAt": "2026-06-17T09:39:45.000Z", "label": "agentrun:tool:commandExecution:started", "type": "tool_call", "status": "running", "itemId": "tool_running_check", "toolName": "commandExecution", "command": "bun run check:tsc" }], "eventCount": 2, "fullTraceLoaded": false, "hasMore": false, "agentRun": { "adapter": "agentrun-v02", "runId": "run_running", "commandId": "cmd_running", "runnerId": "runner_d601", "namespace": "hwlab-v03", "backendProfile": "codex-api", "status": "running", "valuesPrinted": false } }, "trc_completed": { "traceId": "trc_completed", "status": "completed", "sessionId": "ses_completed", "threadId": "thr_completed", "turnId": "turn_completed", "timing": { "startedAt": "2026-06-17T09:37:02.000Z", "lastEventAt": "2026-06-17T09:37:08.000Z", "finishedAt": "2026-06-17T09:37:08.000Z", "durationMs": 6000, "valuesRedacted": true }, "startedAt": "2026-06-17T09:37:02.000Z", "lastEventAt": "2026-06-17T09:37:08.000Z", "finishedAt": "2026-06-17T09:37:08.000Z", "durationMs": 6000, "events": [{ "seq": 1, "createdAt": "2026-06-17T09:37:03.000Z", "label": "agentrun:backend:token_count", "type": "event", "status": "completed" }, { "seq": 2, "createdAt": "2026-06-17T09:37:04.000Z", "label": "agentrun:tool:commandExecution:completed", "type": "tool_call", "status": "completed", "itemId": "tool_completed_build", "toolName": "commandExecution", "command": "bun run build", "stdoutSummary": "build completed", "exitCode": 0 }, { "seq": 3, "createdAt": "2026-06-17T09:37:07.000Z", "label": "agentrun:assistant:message", "type": "assistant_message", "status": "completed", "replyAuthority": true, "final": true, "message": "构建和 Trace 渲染检查完成。" }, { "seq": 4, "createdAt": "2026-06-17T09:37:08.000Z", "label": "agentrun:terminal:completed", "status": "completed", "terminal": true }], "eventCount": 4, "fullTraceLoaded": true, "hasMore": false, "finalResponse": { "text": "构建和 Trace 渲染检查完成。" }, "assistantText": "构建和 Trace 渲染检查完成。", "agentRun": { "adapter": "agentrun-v02", "runId": "run_completed", "commandId": "cmd_completed", "runnerId": "runner_d601", "namespace": "hwlab-v03", "backendProfile": "codex-api", "status": "completed", "valuesPrinted": false } }, "trc_failed": { "traceId": "trc_failed", "status": "failed", "sessionId": "ses_failed", "threadId": "thr_failed", "turnId": "turn_failed", "timing": { "startedAt": "2026-06-17T09:35:03.000Z", "lastEventAt": "2026-06-17T09:35:05.000Z", "finishedAt": "2026-06-17T09:35:05.000Z", "durationMs": 2000, "valuesRedacted": true }, "startedAt": "2026-06-17T09:35:03.000Z", "lastEventAt": "2026-06-17T09:35:05.000Z", "finishedAt": "2026-06-17T09:35:05.000Z", "durationMs": 2000, "events": [{ "seq": 1, "createdAt": "2026-06-17T09:35:04.000Z", "label": "agentrun:tool:commandExecution:completed", "type": "tool_call", "status": "blocked", "itemId": "tool_failed_build", "toolName": "commandExecution", "command": "bun run build", "stderrSummary": "missing controlled dependency", "exitCode": 1 }, { "seq": 2, "createdAt": "2026-06-17T09:35:05.000Z", "label": "agentrun:terminal:failed", "status": "failed", "message": "测试工程编译失败:缺少受控依赖。", "terminal": true, "errorCode": "controlled_dependency_missing" }], "eventCount": 2, "fullTraceLoaded": true, "hasMore": false, "assistantText": "测试工程编译失败:缺少受控依赖。", "error": { "code": "controlled_dependency_missing", "message": "测试工程编译失败:缺少受控依赖。" }, "agentRun": { "adapter": "agentrun-v02", "runId": "run_failed", "commandId": "cmd_failed", "runnerId": "runner_d601", "namespace": "hwlab-v03", "backendProfile": "codex-api", "status": "failed", "valuesPrinted": false } }, "trc_canceled": { "traceId": "trc_canceled", "status": "canceled", "sessionId": "ses_canceled", "threadId": "thr_canceled", "turnId": "turn_canceled", "timing": { "startedAt": "2026-06-17T09:33:02.000Z", "lastEventAt": "2026-06-17T09:33:02.000Z", "finishedAt": "2026-06-17T09:33:02.000Z", "durationMs": 0, "valuesRedacted": true }, "startedAt": "2026-06-17T09:33:02.000Z", "lastEventAt": "2026-06-17T09:33:02.000Z", "finishedAt": "2026-06-17T09:33:02.000Z", "durationMs": 0, "events": [{ "seq": 1, "createdAt": "2026-06-17T09:33:02.000Z", "label": "agentrun:terminal:canceled", "status": "canceled", "message": "用户取消运行。", "terminal": true }], "eventCount": 1, "fullTraceLoaded": true, "hasMore": false, "assistantText": "运行已取消。", "agentRun": { "adapter": "agentrun-v02", "runId": "run_canceled", "commandId": "cmd_canceled", "runnerId": "runner_d601", "namespace": "hwlab-v03", "backendProfile": "codex-api", "status": "canceled", "valuesPrinted": false } } diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts index 9182167d..17e6d441 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/fixtures/selectors.ts @@ -16,6 +16,8 @@ export const selectors = { conversationList: "#conversation-list", messageCard: ".message-card", messageTimingMeta: ".message-timing-meta", + messageDurationMeta: ".message-duration-meta", + messageActivityMeta: ".message-activity-meta", traceTimeline: ".trace-timeline", traceRow: ".trace-render-row", messageDetailDialog: "#message-detail-dialog" 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 82c218f6..5d9e0606 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 @@ -5,25 +5,28 @@ test("Code Agent message headers render canonical timing metadata", async ({ pag await gotoWorkbench(page, "/workbench/sessions/ses_running"); const running = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).first(); - await expect(running.locator(selectors.messageTimingMeta)).toContainText(/最近\s+.+前/u); - await expect(running.locator(selectors.messageTimingMeta)).toContainText("耗时 4 秒"); - await expect(running.locator(selectors.messageTimingMeta)).toHaveAttribute("aria-label", /最近事件:.+前;总耗时:4 秒/u); + await expect(running.locator(`.message-header-main ${selectors.messageDurationMeta}`)).toContainText("耗时 19 分 40 秒"); + await expect(running.locator(`.message-header-actions ${selectors.messageActivityMeta}`)).toContainText("最近 19 分 36 秒前"); + await expect(running.locator(selectors.messageDurationMeta)).toHaveAttribute("aria-label", "总耗时:19 分 40 秒"); + await expect(running.locator(selectors.messageActivityMeta)).toHaveAttribute("aria-label", "最近事件:19 分 36 秒前"); + await expect(running.locator(selectors.messageDurationMeta)).not.toContainText("最近"); await page.locator(sessionTab("ses_completed")).click(); const completed = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).first(); - await expect(completed.locator(selectors.messageTimingMeta)).toContainText("耗时 6 秒"); - await expect(completed.locator(selectors.messageTimingMeta)).not.toContainText("最近"); + await expect(completed.locator(selectors.messageDurationMeta)).toContainText("耗时 6 秒"); + await expect(completed.locator(selectors.messageActivityMeta)).toHaveCount(0); await page.locator(sessionTab("ses_failed")).click(); const failed = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`).first(); - await expect(failed.locator(selectors.messageTimingMeta)).toContainText("耗时 2 秒"); - await expect(failed.locator(selectors.messageTimingMeta)).not.toContainText("最近"); + await expect(failed.locator(selectors.messageDurationMeta)).toContainText("耗时 2 秒"); + await expect(failed.locator(selectors.messageActivityMeta)).toHaveCount(0); await page.locator(sessionTab("ses_canceled")).click(); const canceled = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="canceled"]`).first(); - await expect(canceled.locator(selectors.messageTimingMeta)).toContainText("耗时 0 秒"); - await expect(canceled.locator(selectors.messageTimingMeta)).not.toContainText("最近"); + await expect(canceled.locator(selectors.messageDurationMeta)).toContainText("耗时 0 秒"); + await expect(canceled.locator(selectors.messageActivityMeta)).toHaveCount(0); - await expect(page.locator(`${selectors.messageCard}[data-role="user"] ${selectors.messageTimingMeta}`)).toHaveCount(0); + await expect(page.locator(`${selectors.messageCard}[data-role="user"] ${selectors.messageDurationMeta}`)).toHaveCount(0); + await expect(page.locator(`${selectors.messageCard}[data-role="user"] ${selectors.messageActivityMeta}`)).toHaveCount(0); await saveScreenshot(page, testInfo, "message-timing-headers"); });