Bläddra i källkod

perf(client): avoid replaying settled assistant streams

imccyu 3 veckor sedan
förälder
incheckning
84c11c7243

+ 1 - 11
apps/web/tests/navigation-panes.e2e.ts

@@ -264,17 +264,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
     await page.getByRole('tab', { name: 'Result' }).click()
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
-    const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first()
-    await assistantSpan.hover()
-    const timingTooltip = page.getByRole('tooltip')
-    await timingTooltip.waitFor({ timeout: 5_000 })
-    await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/)
-    const assistantTimingStyle = await assistantSpan.evaluate(node => ({
-      background: getComputedStyle(node).backgroundImage,
-      ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'),
-    }))
-    expect(assistantTimingStyle.background).toContain('linear-gradient')
-    expect(assistantTimingStyle.ttft).toMatch(/%$/)
+    expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBe(0)
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)

+ 4 - 4
apps/web/tests/turn-tail-actions.e2e.ts

@@ -168,7 +168,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
   }, 120_000)
 
   it.skipIf(MODE === 'record')('shows exact completed-Turn usage and expands its available facts', async () => {
-    await launch()
+    await launch(undefined, 5)
     onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-usage-expanded'))
     const { settled } = await sendPrompt(120_000)
     await settled
@@ -200,8 +200,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     await timeTrigger.click()
     const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
     expect(await timeDialog.count()).toBe(1)
-    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(1)
-    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1)
+    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
+    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
     await page.keyboard.press('Escape')
     await trigger.click()
 
@@ -212,7 +212,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
   }, 120_000)
 
   it.skipIf(MODE === 'record')('folds the Turn process after the completed reply becomes the answer', async () => {
-    await launch()
+    await launch(undefined, 5)
     onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-completed'))
     const { settled } = await sendPrompt()
     await settled

+ 15 - 36
packages/client/ui-chat/src/client/conversation-nodes/assistant.ts

@@ -4,9 +4,8 @@ import type {
   ConversationNodeContext, ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
-import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
-import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { AssistantChatData } from '../contract/chat-nodes.ts'
 import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
 import {
@@ -162,15 +161,20 @@ function updateChunk(
   }
 }
 
-function updateEmbedded(
+function settleMessage(
   state: AssistantState,
-  event: Extract<ConversationMatch['event'], { type: 'assistant/message' | 'assistant/attempt' }>,
+  match: ConversationMatch,
+  event: SessionEvent<'assistant/message'>,
 ): AssistantState {
-  let next = state
-  for (const member of expandAssistantStream(event.data.stream)) {
-    next = updateChunk(next, member.chunk, event.seq, member.time)
+  const blocks = toAssistantBlocks(event.data.message.content)
+  return {
+    ...state,
+    blocks,
+    visibleBlocks: countVisibleBlocks(blocks),
+    hidden: false,
+    final: match,
+    usage: event.data.usage,
   }
-  return next
 }
 
 function closedBoundary(location: ConversationLocation): { seq: number; time: number } | undefined {
@@ -232,21 +236,9 @@ function fallbackState(context: ConversationNodeContext<AssistantState>): Assist
       state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time)
       continue
     }
-    if (match.event.type === 'assistant/message' || match.event.type === 'assistant/attempt') {
-      state ??= initialState(match.event.data.turn, match.event.data.step)
-      state = updateEmbedded(state, match.event)
-    }
     if (match.event.type === 'assistant/message') {
       state ??= initialState(match.event.data.turn, match.event.data.step)
-      const blocks = toAssistantBlocks(match.event.data.message.content)
-      state = {
-        ...state,
-        blocks,
-        visibleBlocks: countVisibleBlocks(blocks),
-        hidden: false,
-        final: match,
-        usage: match.event.data.usage,
-      }
+      state = settleMessage(state, match, match.event)
       continue
     }
     if (match.event.type === 'llm/retry' && state !== undefined) {
@@ -304,8 +296,7 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
   match: (event) => {
     if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
     if (event.type === 'assistant/live-chunk'
-      || event.type === 'assistant/attempt'
-      || (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) {
+      || (event.type === 'assistant/message' && event.surfaceOp === 'append')) {
       return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
     }
     if (event.type === 'llm/retry') {
@@ -321,19 +312,7 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
     if (match.event.type === 'assistant/live-chunk') {
       return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
     }
-    if (match.event.type === 'assistant/attempt') return updateEmbedded(context.state, match.event)
-    if (match.event.type === 'assistant/message') {
-      const streamed = updateEmbedded(context.state, match.event)
-      const blocks = toAssistantBlocks(match.event.data.message.content)
-      return {
-        ...streamed,
-        blocks,
-        visibleBlocks: countVisibleBlocks(blocks),
-        hidden: false,
-        final: match,
-        usage: match.event.data.usage,
-      }
-    }
+    if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event)
     if (match.event.type === 'llm/retry') {
       return resetForRetry(context.state)
     }

+ 4 - 9
packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts

@@ -4,8 +4,6 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
-import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
-import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type {} from '@deepseek-ai/dsh-tools/types'
 import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
 import type { AssistantChatData, ChatNode, FinalAssistantChatData } from '../contract/chat-nodes.ts'
@@ -63,11 +61,9 @@ function visibleChunk(chunk: StreamChunk): boolean {
 
 function visibleAssistantEvent(event: ConversationEvent): boolean {
   if (event.type === 'assistant/live-chunk') return visibleChunk(event.data.chunk)
-  if (event.type === 'assistant/attempt') {
-    return expandAssistantStream(event.data.stream).some(member => visibleChunk(member.chunk))
-  }
+  if (event.type === 'assistant/attempt') return false
   return event.type === 'assistant/message'
-    && isAppendSurfaceEvent(event)
+    && event.surfaceOp === 'append'
     && toAssistantBlocks(event.data.message.content).some((block) => {
       if (block.kind === 'tool-call') return false
       if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
@@ -87,7 +83,7 @@ function processEvidence(event: ConversationEvent): ProcessEvidence | undefined
     return { kind: 'assistant', seq: event.seq, step: event.data.step }
   }
   if (event.type === 'tool/call'
-    || (event.type === 'tool/result' && isAppendSurfaceEvent(event))
+    || (event.type === 'tool/result' && event.surfaceOp === 'append')
     || event.type === 'llm/retry') return { kind: 'other', seq: event.seq }
   return undefined
 }
@@ -174,7 +170,7 @@ function processSpec(state: TurnProcessState, turn: TurnLocation): TurnProcessSp
 function updateProcessState(state: TurnProcessState, event: ConversationEvent): TurnProcessState {
   let current = state
   if (event.type === 'assistant/message'
-    && isAppendSurfaceEvent(event)
+    && event.surfaceOp === 'append'
     && hasAssistantReplyContent(toAssistantBlocks(event.data.message.content))) {
     const messageCountByStep = new Map(current.messageCountByStep)
     messageCountByStep.set(event.data.step, (messageCountByStep.get(event.data.step) ?? 0) + 1)
@@ -219,7 +215,6 @@ export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState>
     if (turn === undefined) return null
     if (event.type === 'assistant/live-chunk'
       || event.type === 'assistant/message'
-      || event.type === 'assistant/attempt'
       || event.type === 'tool/call'
       || event.type === 'tool/result'
       || event.type === 'llm/retry'

+ 4 - 12
packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts

@@ -4,8 +4,6 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
-import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
-import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
 import type {
@@ -45,7 +43,7 @@ function isSessionEvent(event: ConversationMatch['event']): event is SessionEven
 
 function hasTextAssistant(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
   return event.type === 'assistant/message'
-    && isAppendSurfaceEvent(event)
+    && event.surfaceOp === 'append'
     && toAssistantBlocks(event.data.message.content)
       .some(block => block.kind === 'text' && block.text.trim() !== '')
 }
@@ -57,12 +55,6 @@ function chunkHasText(chunk: StreamChunk): boolean {
     && chunk.block.text.trim() !== ''
 }
 
-function eventStreamHasText(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
-  if (event.type === 'assistant/live-chunk') return chunkHasText(event.data.chunk)
-  if (event.type !== 'assistant/attempt') return false
-  return expandAssistantStream(event.data.stream).some(member => chunkHasText(member.chunk))
-}
-
 function turnCoordinates(event: Parameters<ConversationNodeDefinition['match']>[0]): {
   readonly turn: number
   readonly step?: number
@@ -92,10 +84,10 @@ function closingAnchor(context: ConversationNodeContext<TurnTailState>): number
     const coordinates = turnCoordinates(event)
     if (coordinates?.step === undefined) continue
     const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false }
-    if (event.type === 'assistant/live-chunk' || event.type === 'assistant/attempt') {
+    if (event.type === 'assistant/live-chunk') {
       steps.set(coordinates.step, {
         ...previous,
-        streamedText: previous.streamedText || eventStreamHasText(event),
+        streamedText: previous.streamedText || chunkHasText(event.data.chunk),
       })
       continue
     }
@@ -145,7 +137,7 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
   for (const match of context.matches) {
     const event = match.event
     const candidate = event.type === 'tool/call'
-      || (event.type === 'tool/result' && isAppendSurfaceEvent(event))
+      || (event.type === 'tool/result' && event.surfaceOp === 'append')
       || (event.type === 'turn/end' && event.data.reason.kind === 'error')
       || event.type === 'llm/retry'
       ? event.seq

+ 16 - 65
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -163,55 +163,6 @@ function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | und
   return value.nodes.values().find(candidate => candidate.kind === kind)
 }
 
-function comparableSnapshot(value: ChatSnapshot) {
-  const nodes = value.nodes.values()
-  return {
-    order: value.order,
-    nodes: nodes.map(candidate => ({
-      ...candidate,
-      location: candidate.location.kind === 'step'
-        ? {
-          kind: 'step',
-          turn: candidate.location.turn.turn,
-          turnStatus: candidate.location.turn.status,
-          step: candidate.location.step.step,
-          stepStatus: candidate.location.step.status,
-        }
-        : candidate.location.kind === 'turn'
-          ? {
-            kind: 'turn',
-            turn: candidate.location.turn.turn,
-            turnStatus: candidate.location.turn.status,
-          }
-          : { kind: candidate.location.kind },
-    })),
-    processes: nodes.map(candidate => [
-      candidate.key,
-      value.nodes.processSource(candidate.key).getSnapshot(),
-    ]),
-    navigation: value.navigation.items(),
-    legacy: value.legacy,
-  }
-}
-
-function withoutEmbeddedSequenceAnchors(value: unknown): unknown {
-  if (Array.isArray(value)) return value.map(withoutEmbeddedSequenceAnchors)
-  if (value instanceof Map) {
-    return new Map([...value].map(([key, entry]) => [key, withoutEmbeddedSequenceAnchors(entry)]))
-  }
-  if (typeof value !== 'object' || value === null) return value
-  return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
-    key,
-    key === 'anchorSeq' || key === 'controlAnchorSeq' || key === 'processStartSeq'
-      ? '<representation-owned-seq>'
-      : withoutEmbeddedSequenceAnchors(entry),
-  ]))
-}
-
-function comparableEmbeddedSnapshot(value: ChatSnapshot): unknown {
-  return withoutEmbeddedSequenceAnchors(comparableSnapshot(value))
-}
-
 function textMessage(id: string, text: string) {
   return {
     id,
@@ -954,7 +905,7 @@ describe('built-in conversation node Definitions', () => {
     })
   })
 
-  it('folds packed Assistant runs to the same Chat content and Turn Tail state as scalar deltas', () => {
+  it('uses live Assistant deltas without replaying settled embedded streams', () => {
     const runningHistory = [
       at(1, 'turn/start', { turn: 1 }),
       at(2, 'step/start', { turn: 1, step: 1 }),
@@ -1001,9 +952,7 @@ describe('built-in conversation node Definitions', () => {
     expect(runningAttempt.data.stream.length).toBeGreaterThan(0)
     const packed = assembler(packedHistory)
 
-    expect(comparableEmbeddedSnapshot(snapshot(packed))).toEqual(comparableEmbeddedSnapshot(snapshot(scalar)))
-    const running = node(snapshot(packed), 'assistant-step')
-    expect(running).toMatchObject({ anchorSeq: 12 })
+    const running = node(snapshot(scalar), 'assistant-step')
     expect(running?.data).toMatchObject({
       time: 1_004,
       blocks: [
@@ -1012,25 +961,25 @@ describe('built-in conversation node Definitions', () => {
         { kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
       ],
     })
+    expect(snapshot(packed).legacy.partial).toBeNull()
+    expect(node(snapshot(packed), 'assistant-step')).toBeUndefined()
 
     for (const value of [scalar, packed]) {
       value.append(at(13, 'step/end', { turn: 1, step: 1 }))
       value.append(at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
       value.flush()
     }
-    expect(comparableEmbeddedSnapshot(snapshot(packed))).toEqual(comparableEmbeddedSnapshot(snapshot(scalar)))
-    expect(node(snapshot(packed), 'turn-tail')?.anchorSeq).toBe(12.2)
+    expect(node(snapshot(scalar), 'assistant-step')?.data).toMatchObject({ status: 'interrupted' })
+    expect(node(snapshot(packed), 'assistant-step')).toBeUndefined()
 
     const partialHistory = [
       ...runningHistory.slice(2),
       at(13, 'step/end', { turn: 1, step: 1 }),
       at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
     ]
-    const partialScalar = snapshot(assembler(partialHistory, true))
     const partialPacked = snapshot(assembler(packedInputs(partialHistory), true))
-    expect(comparableEmbeddedSnapshot(partialPacked)).toEqual(comparableEmbeddedSnapshot(partialScalar))
-    expect(node(partialPacked, 'assistant-step')?.data).toMatchObject({ status: 'interrupted' })
-    expect(node(partialPacked, 'turn-tail')?.anchorSeq).toBe(12.2)
+    expect(partialPacked.legacy.partial).toBeNull()
+    expect(node(partialPacked, 'assistant-step')).toBeUndefined()
 
     const finalizedHistory = [
       at(20, 'turn/start', { turn: 2 }),
@@ -1062,16 +1011,17 @@ describe('built-in conversation node Definitions', () => {
         turn: 2, step: 1, message: assistantMessage('packed-final', 'done'),
       }, { surfaceOp: 'append' }),
     ]
-    const finalizedScalar = snapshot(assembler(finalizedHistory))
     const finalizedInputs = packedInputs(finalizedHistory)
     expect(finalizedInputs.filter(input => input.event.type === 'assistant/attempt')).toHaveLength(1)
     const finalizedMessage = finalizedInputs.find(input => input.event.type === 'assistant/message')?.event
     if (finalizedMessage?.type !== 'assistant/message') throw new Error('expected packed final message')
     expect(finalizedMessage.data.stream.length).toBeGreaterThan(0)
     const finalizedPacked = snapshot(assembler(finalizedInputs))
-    expect(comparableEmbeddedSnapshot(finalizedPacked)).toEqual(comparableEmbeddedSnapshot(finalizedScalar))
     const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
-    expect(finalNode?.timing?.firstTokenTime).toBe(1_999)
+    expect(finalNode).toMatchObject({
+      blocks: [{ kind: 'text', text: 'done' }],
+      timing: { firstTokenTime: null },
+    })
 
     const namedToolHistory = [
       at(40, 'turn/start', { turn: 3 }),
@@ -1089,15 +1039,16 @@ describe('built-in conversation node Definitions', () => {
         },
       }, { surfaceOp: 'append' }),
     ]
-    const namedToolScalar = snapshot(assembler(namedToolHistory))
     const namedToolInputs = packedInputs(namedToolHistory)
     const namedToolMessage = namedToolInputs.find(input => input.event.type === 'assistant/message')?.event
     if (namedToolMessage?.type !== 'assistant/message') throw new Error('expected packed named-tool message')
     expect(namedToolMessage.data.stream.length).toBeGreaterThan(0)
     const namedToolPacked = snapshot(assembler(namedToolInputs))
-    expect(comparableEmbeddedSnapshot(namedToolPacked)).toEqual(comparableEmbeddedSnapshot(namedToolScalar))
     const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
-    expect(namedTool?.timing?.firstTokenTime).toBe(4_000)
+    expect(namedTool).toMatchObject({
+      blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
+      timing: { firstTokenTime: null },
+    })
   })
 
   it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => {

+ 15 - 31
packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts

@@ -5,7 +5,7 @@ import type {
   PartialAssistant, RequestView,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
-import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
+import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import { trajectoryNode } from './trajectory-definition-common.ts'
 import {
   displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks,
@@ -191,15 +191,20 @@ function updateChunk(
   }
 }
 
-function updateEmbedded(
+function settleMessage(
   state: AssistantState,
-  event: Extract<ConversationMatch['event'], { type: 'assistant/message' | 'assistant/attempt' }>,
+  match: ConversationMatch,
+  event: SessionEvent<'assistant/message'>,
 ): AssistantState {
-  let next = state
-  for (const member of expandAssistantStream(event.data.stream)) {
-    next = updateChunk(next, member.chunk, event.seq, member.time)
+  const blocks = toAssistantBlocks(event.data.message.content)
+  return {
+    ...state,
+    sawChunk: false,
+    blocks,
+    visibleBlocks: countVisibleBlocks(blocks),
+    final: match,
+    usage: event.data.usage,
   }
-  return next
 }
 
 function closedBoundary(
@@ -221,18 +226,9 @@ function fallbackState(context: ConversationNodeContext<AssistantState>): Assist
     if (event.type === 'assistant/live-chunk') {
       state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
       state = updateChunk(state, event.data.chunk, event.seq, event.time)
-    } else if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
+    } else if (event.type === 'assistant/message') {
       state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false)
-      state = updateEmbedded(state, event)
-      if (event.type === 'assistant/attempt') continue
-      const blocks = toAssistantBlocks(event.data.message.content)
-      state = {
-        ...state,
-        blocks,
-        visibleBlocks: countVisibleBlocks(blocks),
-        final: match,
-        usage: state.usage ?? event.data.usage,
-      }
+      state = settleMessage(state, match, event)
     } else if (event.type === 'step/end' && state !== undefined) {
       state = { ...state, stepEnd: match }
     }
@@ -329,7 +325,6 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
     }
     if (event.type === 'assistant/live-chunk'
       || event.type === 'assistant/message'
-      || event.type === 'assistant/attempt'
       || event.type === 'llm/retry'
       || event.type === 'step/end') {
       return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
@@ -352,18 +347,7 @@ const trajectoryAssistantDefinition: ConversationNodeDefinition<AssistantState>
     if (match.event.type === 'assistant/live-chunk') {
       return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
     }
-    if (match.event.type === 'assistant/attempt') return updateEmbedded(context.state, match.event)
-    if (match.event.type === 'assistant/message') {
-      const streamed = updateEmbedded(context.state, match.event)
-      const blocks = toAssistantBlocks(match.event.data.message.content)
-      return {
-        ...streamed,
-        blocks,
-        visibleBlocks: countVisibleBlocks(blocks),
-        final: match,
-        usage: streamed.usage ?? match.event.data.usage,
-      }
-    }
+    if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event)
     if (match.event.type === 'step/end') return { ...context.state, stepEnd: match }
     if (match.event.type !== 'llm/retry') return context.state
     const data = match.event.data

+ 9 - 21
packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts

@@ -224,7 +224,7 @@ describe('Trajectory conversation Definitions', () => {
     }])
   })
 
-  it('folds packed Assistant runs to the same Trajectory state as scalar deltas', () => {
+  it('uses live Assistant deltas without replaying settled embedded streams', () => {
     const runningHistory = [
       at(1, 'turn/start', { turn: 1 }),
       at(2, 'step/start', { turn: 1, step: 1 }),
@@ -267,29 +267,20 @@ describe('Trajectory conversation Definitions', () => {
     if (runningAttempt?.type !== 'assistant/attempt') throw new Error('expected packed running attempt')
     expect(runningAttempt.data.stream.length).toBeGreaterThan(0)
     const runningPacked = snapshot(assembler(packedHistory))
-    expect(runningPacked).toEqual(runningScalar)
-    expect(runningPacked.partial?.blocks).toEqual([
+    expect(runningScalar.partial?.blocks).toEqual([
       { kind: 'text', text: '  answer' },
       { kind: 'reasoning', text: 'thinking' },
       { kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
     ])
+    expect(runningPacked.partial).toBeNull()
 
     const partialHistory = [
       ...runningHistory.slice(2),
       at(12, 'step/end', { turn: 1, step: 1 }),
     ]
-    const partialScalar = snapshot(assembler(partialHistory))
     const partialPacked = snapshot(assembler(packedInputs(partialHistory)))
-    expect(partialPacked).toEqual(partialScalar)
-    expect(partialPacked.eventNodes).toMatchObject([{
-      kind: 'assistant',
-      interrupted: true,
-      blocks: [
-        { kind: 'text', text: '  answer' },
-        { kind: 'reasoning', text: 'thinking' },
-        { kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
-      ],
-    }])
+    expect(partialPacked.partial).toBeNull()
+    expect(partialPacked.eventNodes).toEqual([])
 
     const finalizedHistory = [
       at(20, 'turn/start', { turn: 2 }),
@@ -325,20 +316,18 @@ describe('Trajectory conversation Definitions', () => {
       }),
       at(31, 'step/end', { turn: 2, step: 1 }),
     ]
-    const finalizedScalar = snapshot(assembler(finalizedHistory))
     const finalizedInputs = packedInputs(finalizedHistory)
     expect(finalizedInputs.filter(input => input.event.type === 'assistant/attempt')).toHaveLength(1)
     const finalizedMessage = finalizedInputs.find(input => input.event.type === 'assistant/message')?.event
     if (finalizedMessage?.type !== 'assistant/message') throw new Error('expected packed final message')
     expect(finalizedMessage.data.stream.length).toBeGreaterThan(0)
     const finalizedPacked = snapshot(assembler(finalizedInputs))
-    expect(finalizedPacked).toEqual(finalizedScalar)
     expect(finalizedPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
-      timing: { firstTokenTime: 3_000 },
+      blocks: [{ kind: 'text', text: 'done' }],
+      timing: { firstTokenTime: null },
     })
     expect(finalizedPacked.requests).toMatchObject([{
       purpose: 'assistant',
-      usage: { inputTokens: 10, outputTokens: 3 },
       retry: 1,
     }])
 
@@ -358,15 +347,14 @@ describe('Trajectory conversation Definitions', () => {
         },
       }),
     ]
-    const namedToolScalar = snapshot(assembler(namedToolHistory))
     const namedToolInputs = packedInputs(namedToolHistory)
     const namedToolMessage = namedToolInputs.find(input => input.event.type === 'assistant/message')?.event
     if (namedToolMessage?.type !== 'assistant/message') throw new Error('expected packed named-tool message')
     expect(namedToolMessage.data.stream.length).toBeGreaterThan(0)
     const namedToolPacked = snapshot(assembler(namedToolInputs))
-    expect(namedToolPacked).toEqual(namedToolScalar)
     expect(namedToolPacked.eventNodes.find(node => node.kind === 'assistant')).toMatchObject({
-      timing: { firstTokenTime: 4_000 },
+      blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
+      timing: { firstTokenTime: null },
     })
   })
 

+ 1 - 5
snapshots/web/message-actions/ui.expected.md

@@ -46,11 +46,7 @@
   - img
   - text: Read
   - button "b.txt"
-- button "Think This path was interrupted.":
-  - img
-  - img
-  - text: Think This path was interrupted.
-- text: Stopped Now give the final answer. 7/25 {{clock}}
+- text: Now give the final answer. 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: DONE

+ 1 - 2
snapshots/web/navigation-panes/trajectory.expected.md

@@ -4,8 +4,7 @@
   - button "Collapse calls": Calls
   - img
   - searchbox "Search trajectory"
-- region "Trajectory timeline":
-  - tooltip "ASSISTANT {{clock}} → {{clock}} Total {{duration}} · TTFT {{duration}} · Decoding {{duration}}"
+- region "Trajectory timeline"
 - table:
   - rowgroup:
     - row "SYSTEM, Initial System Prompt":