fix: 收敛 workbench 消息头 timing 投影
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,27 +183,28 @@ 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 {
|
||||
const text = textValue(value);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user