fix: 收敛 workbench 消息头 timing 投影

This commit is contained in:
lyon
2026-06-20 14:53:52 +08:00
parent 0d91fe117f
commit 03942afc5c
12 changed files with 213 additions and 182 deletions
+1 -1
View File
@@ -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 };
}
+22 -43
View File
@@ -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) {
@@ -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);
});
+59 -22
View File
@@ -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) {
+26 -25
View File
@@ -93,17 +93,17 @@ function normalizeWorkbenchTurnResult(result: ApiResult<Record<string, unknown>>
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<Record<string, unknown>
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<Record<string, unknown>
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<Record<string, unknown>
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<Record<string, unknown>
};
}
function normalizeTimingProjection(...values: unknown[]): WorkbenchTurnTimingProjection | undefined {
for (const value of values) {
function normalizeTimingProjection(value: unknown): WorkbenchTurnTimingProjection | undefined {
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;
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;
}
return undefined;
}
function normalizeStatus(value: unknown): string | null {
@@ -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<HTMLElement | null>(null);
const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useBottomFollowScroll(panelRef, { threshold: 56 });
const detailMessageId = ref<string | null>(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 {
<header v-if="message.role !== 'user'">
<div class="message-header-main">
<strong>{{ message.title }}</strong>
<span v-if="messageTimingMeta(message)" class="message-timing-meta" :aria-label="messageTimingMeta(message)?.label" :title="messageTimingMeta(message)?.label">
<span v-if="messageTimingMeta(message)?.recent">{{ messageTimingMeta(message)?.recent }}</span>
<span v-if="messageTimingMeta(message)?.duration">{{ messageTimingMeta(message)?.duration }}</span>
</span>
<span v-if="messageDurationMeta(message)" class="message-timing-meta message-duration-meta" :aria-label="messageDurationMeta(message)?.label" :title="messageDurationMeta(message)?.label">{{ messageDurationMeta(message)?.text }}</span>
</div>
<div class="message-header-actions">
<span v-if="messageActivityMeta(message)" class="message-activity-meta" :aria-label="messageActivityMeta(message)?.label" :title="messageActivityMeta(message)?.label">{{ messageActivityMeta(message)?.text }}</span>
<span v-if="isRunningMessage(message)" class="agent-running-indicator" aria-label="运行中" role="status"><span aria-hidden="true" /></span>
<StatusBadge v-if="showStatusBadge(message)" :status="message.status" />
<button v-if="message.role === 'agent'" class="message-detail-button" type="button" aria-label="运行详情" title="运行详情" @click="openDetails(message)">!</button>
+32 -33
View File
@@ -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<ChatMessage["runnerTrace"]>;
@@ -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<string, unknown>).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, TurnStatusAuthority>): 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) {
function normalizeTimingProjection(value: unknown): WorkbenchTurnTimingProjection | null {
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;
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<ChatMessage> {
const timing = normalizeTimingProjection(...values);
function messageTimingPatch(value: unknown): Partial<ChatMessage> {
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 };
}
+16 -6
View File
@@ -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;
+2
View File
@@ -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;
}
@@ -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 } }
@@ -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"
@@ -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");
});