diff --git a/internal/cloud/server-workbench-http.ts b/internal/cloud/server-workbench-http.ts index 1b85bca4..2973c6aa 100644 --- a/internal/cloud/server-workbench-http.ts +++ b/internal/cloud/server-workbench-http.ts @@ -505,7 +505,11 @@ function factSessionSummary(session, facts = {}) { const messages = factMessagesForSession(session, facts); const traceStatus = normalizeTerminalStatus(trace?.status); const checkpointStatus = normalizeTerminalStatus(checkpoint?.status); - const status = normalizeStatus(turn?.status ?? checkpointStatus ?? traceStatus ?? session?.status); + const turnStatus = normalizeStatus(turn?.status); + // Terminal checkpoint/trace evidence is the turn lifecycle authority. A + // stale running turn row must not keep the visible timer running after the + // terminal event has already been projected. + const status = normalizeStatus(checkpointStatus ?? traceStatus ?? turnStatus ?? session?.status); const timingSource = checkpoint ?? (traceId ? null : session); const timing = factTimingProjection(timingSource, status); return { @@ -528,9 +532,9 @@ function factSessionSummary(session, facts = {}) { turnSummary: turn ? { turnId: factTurnId(turn, traceId), traceId, - status: normalizeStatus(turn.status ?? status), - running: RUNNING_STATUSES.has(normalizeStatus(turn.status ?? status)), - terminal: TERMINAL_STATUSES.has(normalizeStatus(turn.status ?? status)) && !RUNNING_STATUSES.has(normalizeStatus(turn.status ?? status)), + status, + running: RUNNING_STATUSES.has(status), + terminal: TERMINAL_STATUSES.has(status) && !RUNNING_STATUSES.has(status), eventCount: projection?.lastProjectedSeq ?? trace?.eventCount, projection, projectionStatus: projection?.projectionStatus ?? null, @@ -587,10 +591,12 @@ function factMessageDto(message, parts = [], facts = {}) { if (!messageId) return null; const traceId = safeTraceId(message?.traceId) ?? null; const role = textValue(message?.role) || "agent"; - const status = normalizeStatus(message?.status); const turn = traceId ? factTurnForTrace(facts, traceId) : null; const checkpoint = traceId ? factCheckpointForTrace(facts, traceId) : null; - const timing = factTimingProjection(isAssistantLikeRole(role) ? turn ?? checkpoint ?? message : message, status); + const messageStatus = normalizeStatus(message?.status); + const checkpointStatus = isAssistantLikeRole(role) ? normalizeTerminalStatus(checkpoint?.status) : null; + const status = normalizeStatus(checkpointStatus ?? (isAssistantLikeRole(role) ? turn?.status : null) ?? messageStatus); + const timing = factTimingProjection(isAssistantLikeRole(role) ? checkpoint ?? turn ?? message : 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) @@ -652,7 +658,9 @@ function factTurnSnapshot({ turn = null, session = null, facts = {}, traceId, tu const assistantMessage = [...messages].reverse().find((message) => isAssistantLikeRole(message.role)) ?? null; const checkpoint = safeTrace ? factCheckpointForTrace(facts, safeTrace) : null; const trace = factTraceSnapshot(facts, safeTrace); - const status = normalizeStatus(turn?.status ?? normalizeTerminalStatus(checkpoint?.status) ?? normalizeTerminalStatus(trace.status) ?? session?.status); + const checkpointStatus = normalizeTerminalStatus(checkpoint?.status); + const traceStatus = normalizeTerminalStatus(trace.status); + const status = normalizeStatus(checkpointStatus ?? traceStatus ?? turn?.status ?? session?.status); const finalText = projectionText(turn?.finalResponse, turn?.assistantText, turn?.text, assistantMessage?.text); const timing = factTimingProjection(checkpoint, status); return {