From c85bce449d67a81ca51ee73db95c51130017d097 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 28 Jun 2026 09:10:17 +0000 Subject: [PATCH] fix: stabilize workbench session timeline --- internal/cloud/server-workbench-http.test.ts | 108 ++++++++++++++++++ internal/cloud/server-workbench-http.ts | 75 +++++++++++- .../src/stores/workbench-server-state.ts | 4 +- web/hwlab-cloud-web/src/stores/workbench.ts | 10 +- 4 files changed, 186 insertions(+), 11 deletions(-) diff --git a/internal/cloud/server-workbench-http.test.ts b/internal/cloud/server-workbench-http.test.ts index ed373258..5f9fbb72 100644 --- a/internal/cloud/server-workbench-http.test.ts +++ b/internal/cloud/server-workbench-http.test.ts @@ -627,6 +627,110 @@ test("workbench session messages use turn timeline instead of clustered projecti } }); +test("workbench session messages use prompt admission time when turn seq is stale", async () => { + const sessionId = "ses_workbench_timeline_stale_turn_seq"; + const turnRows = [ + { traceId: "trc_stale_turn_01", turnSeq: 1, at: "2026-06-28T08:40:42.000Z", prompt: "sentinel-01", reply: "ok-01" }, + { traceId: "trc_stale_turn_02", turnSeq: 3, at: "2026-06-28T08:43:51.000Z", prompt: "sentinel-02", reply: "ok-02" }, + { traceId: "trc_stale_turn_03", turnSeq: 2, at: "2026-06-28T08:44:07.000Z", prompt: "sentinel-03", reply: "ok-03" }, + { traceId: "trc_stale_turn_04", turnSeq: 5, at: "2026-06-28T08:44:27.000Z", prompt: "sentinel-04", reply: "ok-04" }, + { traceId: "trc_stale_turn_05", turnSeq: 4, at: "2026-06-28T08:44:44.000Z", prompt: "sentinel-05", reply: "ok-05" } + ]; + const facts = emptyFacts(); + facts.sessions.push({ + sessionId, + ownerUserId: ACTOR.id, + ownerRole: ACTOR.role, + agentId: "hwlab-code-agent", + status: "completed", + lastTraceId: turnRows.at(-1).traceId, + projectedSeq: 99, + sourceSeq: 99, + terminal: true, + sealed: true, + createdAt: turnRows[0].at, + updatedAt: "2026-06-28T08:45:00.000Z", + valuesRedacted: true + }); + turnRows.forEach((row, index) => { + const userMessageId = `msg_${row.traceId}_user`; + const agentMessageId = `msg_${row.traceId}_agent`; + const finishedAt = new Date(Date.parse(row.at) + 5000).toISOString(); + facts.messages.push( + { messageId: userMessageId, sessionId, turnId: row.traceId, traceId: row.traceId, role: "user", status: "sent", text: row.prompt, projectedSeq: 100 + index, sourceSeq: 100 + index, createdAt: row.at, updatedAt: row.at, valuesRedacted: true }, + { messageId: agentMessageId, sessionId, turnId: row.traceId, traceId: row.traceId, role: "agent", status: "completed", text: row.reply, projectedSeq: 200 + index, sourceSeq: 200 + index, terminal: true, sealed: true, createdAt: finishedAt, updatedAt: finishedAt, timing: { startedAt: row.at, lastEventAt: finishedAt, finishedAt, durationMs: 5000, valuesRedacted: true }, valuesRedacted: true } + ); + facts.parts.push( + { partId: `prt_${row.traceId}_user`, messageId: userMessageId, sessionId, turnId: row.traceId, traceId: row.traceId, partIndex: 0, partType: "text", status: "sent", text: row.prompt, projectedSeq: 100 + index, updatedAt: row.at, valuesRedacted: true }, + { partId: `prt_${row.traceId}_agent`, messageId: agentMessageId, sessionId, turnId: row.traceId, traceId: row.traceId, partIndex: 0, partType: "final_response", status: "completed", text: row.reply, projectedSeq: 200 + index, terminal: true, sealed: true, updatedAt: finishedAt, valuesRedacted: true } + ); + facts.turns.push({ turnId: row.traceId, sessionId, traceId: row.traceId, messageId: agentMessageId, status: "completed", projectedSeq: row.turnSeq, sourceSeq: row.turnSeq, terminal: true, sealed: true, startedAt: row.at, lastEventAt: finishedAt, finishedAt, durationMs: 5000, updatedAt: finishedAt, valuesRedacted: true }); + facts.checkpoints.push({ traceId: row.traceId, sessionId, turnId: row.traceId, status: "completed", projectionStatus: "caught_up", projectionHealth: "caught-up", projectedSeq: row.turnSeq, sourceSeq: row.turnSeq, terminal: true, sealed: true, startedAt: row.at, lastEventAt: finishedAt, finishedAt, durationMs: 5000, updatedAt: finishedAt, valuesRedacted: true }); + }); + const runtimeStore = createRuntimeStoreFromFacts(facts); + const accessController = { + async ensureBootstrap() {}, + async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; } + }; + const server = createCloudApiServer({ accessController, runtimeStore }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const messages = await getJson(port, `/v1/workbench/sessions/${encodeURIComponent(sessionId)}/messages?limit=20`); + assert.equal(messages.status, 200); + assert.deepEqual(messages.body.messages.map((message) => message.text), ["sentinel-01", "ok-01", "sentinel-02", "ok-02", "sentinel-03", "ok-03", "sentinel-04", "ok-04", "sentinel-05", "ok-05"]); + assert.equal(messages.body.roleSequencePrefix, "UAUAUAUAUA"); + assert.equal(messages.body.adjacentSameRoleCount, 0); + assert.match(messages.body.timelineDigest, /^sha256:[0-9a-f]{64}$/u); + + const sessions = await getJson(port, `/v1/workbench/sessions?includeSessionId=${encodeURIComponent(sessionId)}`); + assert.equal(sessions.status, 200); + assert.equal(sessions.body.sessions[0].title, "sentinel-05"); + assert.equal(sessions.body.sessions[0].preview, "ok-05"); + assert.equal(sessions.body.sessions[0].firstUserMessagePreview, "sentinel-01"); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + +test("workbench session list hides shadowed AgentRun alias rows", async () => { + const sessionId = "ses_fake_echo_canonical"; + const aliasSessionId = "ses_agentrun_fake_echo_canonical"; + const traceId = "trc_fake_echo_alias"; + const facts = emptyFacts(); + facts.sessions.push( + { sessionId: aliasSessionId, ownerUserId: ACTOR.id, ownerRole: ACTOR.role, status: "failed", lastTraceId: traceId, projectedSeq: 20, sourceSeq: 20, updatedAt: "2026-06-28T08:52:18.000Z", valuesRedacted: true }, + { sessionId, ownerUserId: ACTOR.id, ownerRole: ACTOR.role, status: "completed", lastTraceId: traceId, projectedSeq: 10, sourceSeq: 10, updatedAt: "2026-06-28T08:52:10.000Z", valuesRedacted: true } + ); + facts.messages.push( + { messageId: "msg_fake_user", sessionId, turnId: traceId, traceId, role: "user", status: "sent", text: "ECHO sentinel-01", projectedSeq: 1, sourceSeq: 1, createdAt: "2026-06-28T08:52:00.000Z", updatedAt: "2026-06-28T08:52:00.000Z", valuesRedacted: true }, + { messageId: "msg_fake_agent", sessionId, turnId: traceId, traceId, role: "agent", status: "completed", text: "ECHO sentinel-01 ok", projectedSeq: 2, sourceSeq: 2, terminal: true, sealed: true, createdAt: "2026-06-28T08:52:05.000Z", updatedAt: "2026-06-28T08:52:05.000Z", valuesRedacted: true } + ); + facts.parts.push( + { partId: "prt_fake_user", messageId: "msg_fake_user", sessionId, turnId: traceId, traceId, partIndex: 0, partType: "text", status: "sent", text: "ECHO sentinel-01", projectedSeq: 1, valuesRedacted: true }, + { partId: "prt_fake_agent", messageId: "msg_fake_agent", sessionId, turnId: traceId, traceId, partIndex: 0, partType: "final_response", status: "completed", text: "ECHO sentinel-01 ok", projectedSeq: 2, terminal: true, sealed: true, valuesRedacted: true } + ); + const runtimeStore = createRuntimeStoreFromFacts(facts); + const accessController = { + async ensureBootstrap() {}, + async authenticate() { return { ok: true, actor: ACTOR, session: { id: "uss_workbench_reader" } }; } + }; + const server = createCloudApiServer({ accessController, runtimeStore }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const sessions = await getJson(port, "/v1/workbench/sessions?limit=10"); + assert.equal(sessions.status, 200); + assert.deepEqual(sessions.body.sessions.map((session) => session.sessionId), [sessionId]); + assert.equal(sessions.body.sessions[0].title, "ECHO sentinel-01"); + assert.equal(sessions.body.sessions[0].preview, "ECHO sentinel-01 ok"); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + test("workbench trace event continuation returns empty catch-up page instead of 404", async () => { const traceId = "trc_workbench_trace_events_catchup"; const session = { @@ -2193,6 +2297,10 @@ async function waitForCondition(predicate, timeoutMs = 500) { function createDurableFactsRuntimeStore({ sessions = [], queryError = null, queries = [] } = {}) { const facts = emptyFacts(); for (const item of sessions) mergeFacts(facts, buildDurableFactsForSession(item)); + return createRuntimeStoreFromFacts(facts, { queryError, queries }); +} + +function createRuntimeStoreFromFacts(facts, { queryError = null, queries = [] } = {}) { return { queries, async queryWorkbenchFacts(params = {}) { diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 12254daf..3132172c 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -1,5 +1,5 @@ /* - * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-20-p2-terminal-outbox-recovery; draft-2026-06-27-p0-read-model-timeline-contract; PJ2026-010403 API契约 draft-2026-06-20-p2-terminal-outbox-recovery; draft-2026-06-27-p0-workbench-read-api-contract; PJ2026-010401 Web工作台 draft-2026-06-20-p2-terminal-outbox-recovery; draft-2026-06-27-p0-workbench-read-model-contract; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0 + * SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-20-p2-terminal-outbox-recovery; draft-2026-06-27-p0-read-model-timeline-contract; draft-2026-06-28-p0-d518-session-timeline-consistency; PJ2026-010403 API契约 draft-2026-06-20-p2-terminal-outbox-recovery; draft-2026-06-27-p0-workbench-read-api-contract; PJ2026-010401 Web工作台 draft-2026-06-20-p2-terminal-outbox-recovery; draft-2026-06-27-p0-workbench-read-model-contract; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0 * 职责: Workbench REST read model。只读投影 session/message/turn/trace facts,不执行 AgentRun sync、billing finalize 或 workspace repair。 */ import { createHash } from "node:crypto"; @@ -890,9 +890,22 @@ async function queryFactsForSessionSummaries(readModel, sessions = []) { } function visibleFactSessions(facts, actor) { - return factArray(facts?.sessions) + return hideShadowedAgentRunAliasSessions(factArray(facts?.sessions) .filter((session) => canActorReadFactSession(session, actor)) - .sort(compareFactSessionsDesc); + .sort(compareFactSessionsDesc)); +} + +function hideShadowedAgentRunAliasSessions(sessions = []) { + const canonicalTraceIds = new Set(sessions + .filter((session) => !isAgentRunAliasSessionId(factSessionId(session))) + .map(factLastTraceId) + .filter(Boolean)); + if (canonicalTraceIds.size === 0) return sessions; + return sessions.filter((session) => !isAgentRunAliasSessionId(factSessionId(session)) || !canonicalTraceIds.has(factLastTraceId(session))); +} + +function isAgentRunAliasSessionId(sessionId) { + return /^ses_agentrun_/u.test(textValue(sessionId)); } function canActorReadFactSession(session, actor) { @@ -1553,7 +1566,8 @@ function compareFactMessagesAsc(left, right) { function compareFactMessagesForSessionAsc(left, right, facts = {}) { const leftKey = factMessageTimelineKey(left, facts); const rightKey = factMessageTimelineKey(right, facts); - return compareOptionalNumberAsc(leftKey.turnSeq, rightKey.turnSeq) + return compareOptionalTimestampAsc(leftKey.timelineAnchorAt, rightKey.timelineAnchorAt) + || compareOptionalNumberAsc(leftKey.turnSeq, rightKey.turnSeq) || compareTimestampAsc(leftKey.turnStartedAt, rightKey.turnStartedAt) || compareText(leftKey.traceId, rightKey.traceId) || compareOptionalNumberAsc(leftKey.roleRank, rightKey.roleRank) @@ -1566,8 +1580,10 @@ function factMessageTimelineKey(message, facts = {}) { const traceId = safeTraceId(message?.traceId) ?? null; const turn = traceId ? factTurnForTrace(facts, traceId) : null; const checkpoint = traceId ? factCheckpointForTrace(facts, traceId) : null; + const anchorMessage = traceId ? factTimelineAnchorMessageForTrace(facts, traceId) : null; return { traceId: traceId ?? "", + timelineAnchorAt: firstTimestampIso(anchorMessage?.createdAt, anchorMessage?.updatedAt), turnSeq: firstFiniteNumber(factSeq(turn), factSeq(checkpoint)), turnStartedAt: firstTimestampIso(turn?.startedAt, checkpoint?.startedAt, message?.createdAt, message?.updatedAt), roleRank: factMessageTimelineRoleRank(message?.role), @@ -1576,6 +1592,15 @@ function factMessageTimelineKey(message, facts = {}) { }; } +function factTimelineAnchorMessageForTrace(facts = {}, traceId) { + const safeTrace = safeTraceId(traceId); + if (!safeTrace) return null; + const messages = factArray(facts.messages).filter((message) => message.traceId === safeTrace); + const userMessages = messages.filter((message) => normalizeRole(message?.role) === "user"); + const candidates = userMessages.length > 0 ? userMessages : messages; + return [...candidates].sort((left, right) => compareOptionalTimestampAsc(left?.createdAt ?? left?.updatedAt, right?.createdAt ?? right?.updatedAt) || compareOptionalNumberAsc(factSeq(left), factSeq(right)) || compareText(left?.messageId, right?.messageId))[0] ?? null; +} + function factMessageTimelineRoleRank(role) { const normalized = normalizeRole(role); if (normalized === "user") return 0; @@ -1619,6 +1644,12 @@ function compareTimestampDesc(left, right) { return timestampMs(right) - timestampMs(left); } +function compareOptionalTimestampAsc(left, right) { + const leftMs = optionalTimestampMs(left); + const rightMs = optionalTimestampMs(right); + return (leftMs ?? Number.MAX_SAFE_INTEGER) - (rightMs ?? Number.MAX_SAFE_INTEGER); +} + function compareNumberAsc(left, right) { return numericValue(left, Number.MAX_SAFE_INTEGER) - numericValue(right, Number.MAX_SAFE_INTEGER); } @@ -1636,6 +1667,11 @@ function timestampMs(value) { return Number.isFinite(parsed) ? parsed : 0; } +function optionalTimestampMs(value) { + const parsed = Date.parse(String(value ?? "")); + return Number.isFinite(parsed) ? parsed : null; +} + function numericValue(value, fallback) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; @@ -1737,6 +1773,10 @@ async function handleWorkbenchMessagePage(request, response, url, options, actor cursor: offset > 0 ? cursorFromOffset(offset) : null, nextCursor: nextOffset < messages.length ? cursorFromOffset(nextOffset) : null, hasMore: nextOffset < messages.length, + timelineDigest: workbenchTimelineDigest(page), + roleSequencePrefix: messagesRoleSequence(page).slice(0, 32), + adjacentSameRoleCount: adjacentSameRoleCount(page), + traceIds: uniqueText(page.map((message) => message?.traceId)).slice(0, 16), valuesRedacted: true, secretMaterialStored: false }; @@ -2098,7 +2138,7 @@ function emitWorkbenchSessionMessagesReadOtel(request, options = {}, fields = {} const messages = Array.isArray(fields.messages) ? fields.messages : []; const traceIds = uniqueText(messages.map((message) => message?.traceId)).slice(0, 8); const businessTraceId = (safeTraceId(traceIds[0]) ?? textValue(fields.sessionId)) || "workbench_session_messages"; - const roleSequence = messages.map((message) => roleSequenceSymbol(message?.role)).join(""); + const roleSequence = messagesRoleSequence(messages); void emitCodeAgentOtelSpan("session_messages_read", businessTraceId, options.env ?? process.env, { startTimeMs: fields.startTimeMs, status: error ? "error" : "ok", @@ -2116,6 +2156,7 @@ function emitWorkbenchSessionMessagesReadOtel(request, options = {}, fields = {} roleSequencePrefix: roleSequence.slice(0, 32), consecutiveUserPrefix: consecutiveRolePrefix(messages, "user"), adjacentSameRoleCount: adjacentSameRoleCount(messages), + timelineDigest: workbenchTimelineDigest(messages), userCount: messages.filter((message) => normalizeRole(message?.role) === "user").length, agentCount: messages.filter((message) => isAssistantLikeRole(message?.role)).length, traceIds: traceIds.join(","), @@ -2124,6 +2165,30 @@ function emitWorkbenchSessionMessagesReadOtel(request, options = {}, fields = {} }); } +function messagesRoleSequence(messages = []) { + return messages.map((message) => roleSequenceSymbol(message?.role)).join(""); +} + +function workbenchTimelineDigest(messages = []) { + const payload = messages.map((message) => { + const terminal = isTerminalProjectionStatus(normalizeStatus(message?.status)) || Boolean(message?.finishedAt); + return { + messageId: message?.messageId ?? null, + role: normalizeRole(message?.role), + sessionId: message?.sessionId ?? null, + traceId: message?.traceId ?? null, + turnId: message?.turnId ?? null, + status: normalizeStatus(message?.status), + partIds: factArray(message?.parts).map((part) => part?.partId ?? null), + textHash: textValue(message?.text || message?.textPreview) ? hash(textValue(message?.text || message?.textPreview)).slice(0, 16) : null, + startedAt: message?.startedAt ?? null, + finishedAt: terminal ? message?.finishedAt ?? null : null, + durationMs: terminal && Number.isFinite(Number(message?.durationMs)) ? Math.trunc(Number(message.durationMs)) : null + }; + }); + return `sha256:${hash(JSON.stringify(payload))}`; +} + function sessionSummaryNeedsTitleFallback(session) { if (!session || typeof session !== "object") return true; return !sessionPreviewText(session); diff --git a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts index be160560..a3e5b95f 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-server-state.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-server-state.ts @@ -1,4 +1,4 @@ -// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection. +// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; draft-2026-06-28-p0-d518-session-timeline-consistency. // Responsibility: Normalized Workbench server-state reducer for session, turn, and trace caches. import type { ChatMessage, ProjectionDiagnostic, WorkbenchSessionRecord } from "@/types"; @@ -228,7 +228,7 @@ function sealTerminalTransitionMessageTiming(existing: ChatMessage, incoming: Ch const existingTiming = messageTimingProjection(existing); const startedAt = existingTiming?.startedAt ?? incomingTiming?.startedAt ?? timestampOrNull(existing.createdAt) ?? null; const finishedAt = terminalFinishedAt(startedAt, incomingTiming?.finishedAt, timestampOrNull(incoming.updatedAt), incomingTiming?.lastEventAt) ?? incomingTiming?.finishedAt ?? null; - const durationMs = positiveDuration(durationBetween(startedAt, finishedAt)) ?? positiveDuration(durationBetween(startedAt, new Date().toISOString())) ?? positiveDuration(incomingTiming?.durationMs) ?? (incomingTiming?.durationMs === 0 ? 1000 : null); + const durationMs = positiveDuration(durationBetween(startedAt, finishedAt)) ?? positiveDuration(incomingTiming?.durationMs) ?? (incomingTiming?.durationMs === 0 ? 0 : null); if (durationMs === null) return incoming; const timing: NonNullable = { ...(incomingTiming ?? {}), diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 90051a4b..b085c719 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -1,4 +1,4 @@ -// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. +// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; draft-2026-06-28-p0-d518-session-timeline-consistency; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. // Responsibility: Session-first Workbench state orchestration for selection, turn admission, and trace lifecycle rendering. import { computed, nextTick, ref } from "vue"; @@ -2127,7 +2127,8 @@ function messageTimingPatchForMerge(message: ChatMessage, value: unknown): Parti if (incomingDuration !== null) return messageTimingPatchFromProjection({ ...(existingTiming ?? {}), ...incomingTiming, durationMs: incomingDuration, valuesRedacted: incomingTiming.valuesRedacted !== false && existingTiming?.valuesRedacted !== false }); const runningDuration = isTerminalMessageStatus(message.status) ? null : runningDurationFromTiming(existingTiming); if (runningDuration !== null) { - const finishedAt = incomingTiming.finishedAt ?? incomingTiming.lastEventAt ?? new Date().toISOString(); + const finishedAt = incomingTiming.finishedAt ?? incomingTiming.lastEventAt ?? null; + if (!finishedAt) return terminalMessageTimingPatchForNormalize(value); return messageTimingPatchFromProjection({ ...(incomingTiming ?? {}), startedAt: existingTiming?.startedAt ?? incomingTiming.startedAt ?? null, lastEventAt: incomingTiming.lastEventAt ?? finishedAt, finishedAt, durationMs: runningDuration, valuesRedacted: incomingTiming.valuesRedacted !== false && existingTiming?.valuesRedacted !== false }); } return terminalMessageTimingPatchForNormalize(value); @@ -2180,7 +2181,8 @@ function messageTerminalFromRunningSealPatchForProjectionMerge(message: ChatMess if (runningDuration === null) return incomingPatch; const incomingDuration = firstPositiveFiniteNumber(incomingTiming?.durationMs) ?? positiveDurationBetween(incomingTiming?.startedAt, incomingTiming?.finishedAt) ?? positiveDurationBetween(incomingTiming?.startedAt, incomingTiming?.lastEventAt); const durationMs = Math.max(incomingDuration ?? 0, runningDuration); - const finishedAt = incomingTiming?.finishedAt ?? incomingTiming?.lastEventAt ?? new Date().toISOString(); + const finishedAt = incomingTiming?.finishedAt ?? incomingTiming?.lastEventAt ?? null; + if (!finishedAt) return incomingPatch; const timing = { ...(incomingTiming ?? {}), startedAt: previousTiming?.startedAt ?? incomingTiming?.startedAt ?? null, @@ -2210,7 +2212,7 @@ function terminalMessageTimingPatchForNormalize(value: unknown): Partial