diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 96506e9a0e..b6bc4ae6d5 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -52,6 +52,20 @@ function hasStructuralParts(message: ChatMessage): boolean { return message.parts.some(part => part.type === 'reasoning' || part.type === 'tool-call') } +/** + * A live-turn row — the gateway's text-only `inflight` projection, a + * still-streaming local bubble, or an interim row sealed inside the running + * turn — as opposed to a committed transcript row. + */ +function isLiveTailRow(message: ChatMessage): boolean { + return ( + message.pending === true || + message.id.startsWith('assistant-stream-') || + message.id.startsWith('inflight-assistant-') || + message.interim === true + ) +} + /** * True when `next` is a pure forward extension of the previous *answer* text. * Empty previous answer never accepts a dump as an extension — that is how the @@ -301,11 +315,6 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes // live is not enough — after compression a new live assistant can share a // role ordinal with an unrelated historical structured row and must not // inherit its reasoning/tool parts (#76444 review / salvage). - const isLiveTailRow = (row: ChatMessage): boolean => - Boolean(row.pending) || - row.id.startsWith('assistant-stream-') || - Boolean(row.interim) - const sameTurn = sameText || (nextText.length > 0 && previousTrimmed.length > 0 && isStrictAnswerTextExtension(nextText, previousTrimmed)) || @@ -394,19 +403,12 @@ const isGatewaySystemMarker = (message: ChatMessage): boolean => message.role === 'user' && chatMessageText(message).trimStart().startsWith('[System:') /** - * Does the local pending row carry anything a viewer would miss — streamed - * text, reasoning, or tool calls? An empty inflight shell carries none of it. + * Does the row carry anything a viewer would miss — streamed answer text, or + * the reasoning / tool-call structure the gateway's flat dump cannot express? + * An empty inflight shell carries none of it. */ const hasStreamedContent = (message: ChatMessage): boolean => - chatMessageText(message).trim().length > 0 || - message.parts.some(part => part.type === 'tool-call' || part.type === 'reasoning') - -/** - * A live-turn projection row — the gateway's text-only `inflight` snapshot or a - * still-streaming local bubble — as opposed to a committed transcript row. - */ -const isLiveProjectionRow = (message: ChatMessage): boolean => - message.pending === true || message.id.startsWith('assistant-stream-') || message.id.startsWith('inflight-assistant-') + chatMessageText(message).trim().length > 0 || hasStructuralParts(message) /** * May the cached local row stand in for this authoritative assistant? @@ -419,11 +421,11 @@ const isLiveProjectionRow = (message: ChatMessage): boolean => * from the local partial would hide the error and mark the turn healthy again. */ const localPendingSupersedes = (local: ChatMessage, authoritative: ChatMessage): boolean => { - if (local.role !== 'assistant' || !isLiveProjectionRow(local)) { + if (local.role !== 'assistant' || !isLiveTailRow(local)) { return false } - if (!isLiveProjectionRow(authoritative) || authoritative.error) { + if (!isLiveTailRow(authoritative) || authoritative.error) { return false } @@ -435,7 +437,7 @@ const localPendingSupersedes = (local: ChatMessage, authoritative: ChatMessage): const localText = chatMessageText(local).trim() - return localText.length > authoritativeText.length && localText.startsWith(authoritativeText) + return localText.length > authoritativeText.length && isStrictAnswerTextExtension(localText, authoritativeText) } /** @@ -705,28 +707,32 @@ export function appendLiveSessionProjection( // Only inspect the live tail after the latest user run — never a completed // historical tool-bearing reply earlier in the transcript (review feedback). const liveStreamId = `assistant-stream-${sessionId}` - const liveAssistantOfCurrentTurn = (() => { + + const liveAssistantOfCurrentTurn = ((): ChatMessage | null => { const byStreamId = messages.find(message => message.id === liveStreamId) + if (byStreamId) { return byStreamId } + // Assistants after the latest user row belong to this turn's tail. if (latestUserIndex < 0) { return null } + for (let index = messages.length - 1; index > latestUserIndex; index -= 1) { if (messages[index].role === 'assistant') { return messages[index] } } + return null })() + const turnAlreadyStructured = Boolean( liveAssistantOfCurrentTurn && - hasStructuralParts(liveAssistantOfCurrentTurn) && - (liveAssistantOfCurrentTurn.pending || - liveAssistantOfCurrentTurn.id === liveStreamId || - liveAssistantOfCurrentTurn.interim) + hasStructuralParts(liveAssistantOfCurrentTurn) && + isLiveTailRow(liveAssistantOfCurrentTurn) ) if (inflightAssistant || inflightStreaming || inflightError || (inflightUser && queuedUser)) {