Sfoglia il codice sorgente

fix(ui-trajectory): place steering before request boundary

imccyu 2 mesi fa
parent
commit
d66e693c63

+ 26 - 7
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -492,6 +492,21 @@ function requestKey(turn: number | null, group: string): string {
   return `${turn}\u0000${group}`
 }
 
+function indexRequestBoundaries(records: readonly TableRecord[]): ReadonlyMap<string, number> {
+  const boundaries = new Map<string, number>()
+  for (const record of records) {
+    const key = requestKey(record.turn, record.group)
+    if (boundaries.has(key)) continue
+    if (requestStep(record.group) === undefined) {
+      if (record.groupStart) boundaries.set(key, record.cell.index)
+      continue
+    }
+    if (record.cell.kind === 'user' || record.cell.kind === 'context') continue
+    boundaries.set(key, record.cell.index)
+  }
+  return boundaries
+}
+
 function sectionLabel(turn: number | null): string {
   return turn === null ? 'Between turns' : `Turn ${turn}`
 }
@@ -499,16 +514,18 @@ function sectionLabel(turn: number | null): string {
 function indexRequestNumbers(
   records: readonly TableRecord[],
   sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
+  boundaries: ReadonlyMap<string, number>,
 ): ReadonlyMap<string, number> {
   const numbers = new Map<string, number>()
   for (const request of sessionNumbers ?? []) {
     numbers.set(requestKey(request.turn, request.group), request.number)
   }
   let next = Math.max(0, ...numbers.values()) + 1
-  const boundaries = records
-    .filter(record => record.groupStart && requestStep(record.group) !== undefined)
+  const boundaryRecords = records
+    .filter(record => boundaries.get(requestKey(record.turn, record.group)) === record.cell.index
+      && requestStep(record.group) !== undefined)
     .sort((left, right) => left.cell.index - right.cell.index)
-  for (const record of boundaries) {
+  for (const record of boundaryRecords) {
     const key = requestKey(record.turn, record.group)
     if (!numbers.has(key)) numbers.set(key, next++)
   }
@@ -1731,9 +1748,10 @@ export function TrajectoryTable({
   useEffect(() => {
     onSelectedIndexChange?.(selectedIndex)
   }, [onSelectedIndexChange, selectedIndex])
+  const requestBoundaries = useMemo(() => indexRequestBoundaries(allRecords), [allRecords])
   const requestNumbers = useMemo(
-    () => indexRequestNumbers(allRecords, sessionRequestNumbers),
-    [allRecords, sessionRequestNumbers],
+    () => indexRequestNumbers(allRecords, sessionRequestNumbers, requestBoundaries),
+    [allRecords, requestBoundaries, sessionRequestNumbers],
   )
   const records = useMemo(() => {
     if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
@@ -2218,10 +2236,11 @@ export function TrajectoryTable({
                   const isRequestOnly = record.cell.requestOnly === true
                   const isInitialSystem = record.cell.kind === 'system'
                 && record.cell.index === allRecords[0]?.cell.index
-                  const request = record.groupStart
+                  const key = requestKey(record.turn, record.group)
+                  const request = requestBoundaries.get(key) === record.cell.index
                 && !isCollapsedSummary
                 && (record.turn === null || !collapsedTurns.has(record.turn))
-                    ? requestNumbers.get(requestKey(record.turn, record.group))
+                    ? requestNumbers.get(key)
                     : undefined
                   const requestInfo = request === undefined
                     ? undefined

+ 3 - 1
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -145,6 +145,7 @@ export function TrajectoryView({
     snapshot.openState === 'loading' || snapshot.loadingOlder)
   const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
   const nodes = inspection.eventNodes
+  const eventLocations = inspection.eventLocations
   const historyBaseSeq = nodes[0]?.seq ?? 0
   const partial = inspection.partial
   const runningCalls = inspection.runningCalls
@@ -254,6 +255,7 @@ export function TrajectoryView({
   const finalized = useMemo(() => {
     const turns = deriveTrajectoryLayout({
       nodes,
+      eventLocations,
       partial: partialTurn === null || partialStep === null
         ? null
         : { turn: partialTurn, step: partialStep, blocks: [] },
@@ -263,7 +265,7 @@ export function TrajectoryView({
     })
     return { turns, lastIndex: lastCellIndex(turns) }
   }, [
-    nodes, partialTurn, partialStep,
+    nodes, eventLocations, partialTurn, partialStep,
     runningCalls, requests, callSchemas,
   ])
   const timelinePartialSignature = partialStructureSignature(partial)

+ 79 - 12
packages/client/ui-trajectory/src/client/layout.ts

@@ -5,6 +5,7 @@
 import type {
   AssistantBlock,
   AssistantMessageNode,
+  ConversationLocation,
   ConversationSnapshot,
   RequestInspectionSnapshot,
   RequestPromptChange,
@@ -34,6 +35,7 @@ export interface TrajectoryTurnModel {
 /** Snapshot slice the trajectory view folds. */
 export interface TrajectoryLayoutInput {
   nodes: ConversationSnapshot['nodes']
+  eventLocations?: ReadonlyMap<number, ConversationLocation>
   partial: ConversationSnapshot['partial']
   runningCalls: ConversationSnapshot['runningCalls']
   requests?: readonly RequestView[]
@@ -71,7 +73,7 @@ type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
 
 type InputNode = Extract<
   ConversationSnapshot['nodes'][number],
-  { kind: 'user' | 'context' }
+  { kind: 'user' | 'steering' | 'context' }
 >
 
 type OrderedLayoutEntry =
@@ -135,12 +137,13 @@ function inputCellDetail(node: InputNode): Pick<
  */
 export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
   const {
-    nodes, partial, runningCalls, requests = [], callSchemas,
+    nodes, eventLocations, partial, runningCalls, requests = [], callSchemas,
   } = input
   const resultByCall = indexResults(nodes)
   const callById = new Map<string, ToolCallBlock>(resultByCall)
   for (const call of runningCalls) callById.set(call.callId, call)
   const emittedCallIds = indexAssistantCallIds(nodes)
+  const followingAssistants = indexFollowingAssistants(nodes)
   const callStartById = new Map<string, number>()
   for (const result of resultByCall.values()) {
     const startedAt = finiteTime(result.callTime)
@@ -185,6 +188,19 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
     }
     groups.push({ title, laid: [...laid] })
   }
+  const pushStepInput = (turn: number, step: number, laid: readonly LaidCell[]) => {
+    if (laid.length === 0) return
+    const groups = bucket(turn).groups
+    const title = `Step ${step}`
+    const existing = groups.find(group => group.title === title)
+    if (existing === undefined) {
+      groups.push({ title, laid: [...laid] })
+      return
+    }
+    const request = existing.laid.findIndex(entry => entry.cell.requestOnly === true)
+    if (request === -1) existing.laid.push(...laid)
+    else existing.laid.splice(request, 0, ...laid)
+  }
 
   const representedRequests = new Set<string>()
   for (const node of nodes) {
@@ -338,7 +354,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
     if (node.kind === 'user') {
       // user/message has no turn on the wire; enclose it in the next assistant
       // (or partial) turn, else open the turn after the last assistant.
-      const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
+      const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
       pushMessage(turn, {
         absTime: finiteTime(node.time),
         cell: {
@@ -351,6 +367,26 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
       prevAbsTime = finiteTime(node.time) ?? prevAbsTime
       continue
     }
+    if (node.kind === 'steering') {
+      const placement = steeringPlacement(
+        followingAssistants[i],
+        partial,
+        lastAssistantTurn,
+        eventLocations?.get(node.seq),
+      )
+      const laid = {
+        absTime: finiteTime(node.time),
+        cell: {
+          index: ++index,
+          kind: 'user' as const,
+          ...inputCellDetail(node),
+        },
+      }
+      if (placement.step === undefined) pushMessage(placement.turn, laid)
+      else pushStepInput(placement.turn, placement.step, [laid])
+      prevAbsTime = finiteTime(node.time) ?? prevAbsTime
+      continue
+    }
     if (node.kind === 'assistant') {
       const laidList = withSubCalls(
         expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
@@ -364,7 +400,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
       continue
     }
     if (node.kind === 'context') {
-      const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
+      const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn)
       pushMessage(turn, {
         absTime: finiteTime(node.time),
         cell: {
@@ -821,22 +857,53 @@ function stringifySourceValue(value: unknown): string {
  * in-flight partial, else the turn after the last finalized assistant (or 1).
  */
 function enclosingUserTurn(
-  nodes: ConversationSnapshot['nodes'],
-  userIndex: number,
+  followingAssistant: AssistantMessageNode | undefined,
   partial: ConversationSnapshot['partial'],
   lastAssistantTurn: number | null,
 ): number {
-  for (let i = userIndex + 1; i < nodes.length; i++) {
-    const n = nodes[i]
-    /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
-    if (n === undefined) continue
-    if (n.kind === 'assistant') return n.turn
-  }
+  if (followingAssistant !== undefined) return followingAssistant.turn
   if (partial !== null) return partial.turn
   if (lastAssistantTurn !== null) return lastAssistantTurn + 1
   return 1
 }
 
+function steeringPlacement(
+  followingAssistant: AssistantMessageNode | undefined,
+  partial: ConversationSnapshot['partial'],
+  lastAssistantTurn: number | null,
+  location: ConversationLocation | undefined,
+): { turn: number; step?: number } {
+  if (location?.kind === 'step') {
+    return { turn: location.turn.turn, step: location.step.step }
+  }
+  const locatedTurn = location?.kind === 'turn' ? location.turn.turn : undefined
+  if (followingAssistant !== undefined
+    && (locatedTurn === undefined || followingAssistant.turn === locatedTurn)) {
+    return {
+      turn: followingAssistant.turn,
+      ...(followingAssistant.step > 0 ? { step: followingAssistant.step } : {}),
+    }
+  }
+  if (partial !== null && (locatedTurn === undefined || partial.turn === locatedTurn)) {
+    return { turn: partial.turn, ...(partial.step > 0 ? { step: partial.step } : {}) }
+  }
+  if (locatedTurn !== undefined) return { turn: locatedTurn }
+  return { turn: lastAssistantTurn ?? 1 }
+}
+
+function indexFollowingAssistants(
+  nodes: ConversationSnapshot['nodes'],
+): readonly (AssistantMessageNode | undefined)[] {
+  const following = new Array<AssistantMessageNode | undefined>(nodes.length)
+  let assistant: AssistantMessageNode | undefined
+  for (let index = nodes.length - 1; index >= 0; index--) {
+    following[index] = assistant
+    const node = nodes[index]
+    if (node?.kind === 'assistant') assistant = node
+  }
+  return following
+}
+
 function enclosingPromptTurn(
   nodes: ConversationSnapshot['nodes'],
   seq: number,

+ 2 - 0
packages/client/ui-trajectory/src/client/trajectory-contract.ts

@@ -53,12 +53,14 @@ export type TrajectoryContribution =
 export interface TrajectoryConversationViewNode extends ConversationViewNode {
   readonly target: 'trajectory'
   readonly anchorSeq: number
+  readonly location: ConversationLocation
   readonly data: TrajectoryContribution
 }
 
 /** Stage-oriented Trajectory data assembled from registered business Contexts. */
 export interface TrajectorySnapshot {
   readonly eventNodes: readonly ConversationNode[]
+  readonly eventLocations: ReadonlyMap<number, ConversationLocation>
   readonly requests: readonly RequestView[]
   readonly callSchemas: ReadonlyMap<string, ConversationPromptSnapshot['tools'][number]>
   readonly partial: PartialAssistant | null

+ 1 - 0
packages/client/ui-trajectory/src/client/trajectory-definition-common.ts

@@ -22,6 +22,7 @@ export function trajectoryNode(
     id: context.id,
     target: 'trajectory',
     anchorSeq,
+    location: context.start?.location ?? { kind: 'unresolved' },
     data,
   }
 }

+ 4 - 0
packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts

@@ -16,6 +16,7 @@ type ToolSchema = ConversationPromptSnapshot['tools'][number]
 /** Stable empty target used until a Session has assembled Trajectory records. */
 export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = {
   eventNodes: EMPTY_LIST,
+  eventLocations: new Map(),
   requests: EMPTY_LIST,
   callSchemas: new Map(),
   partial: null,
@@ -179,6 +180,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
       if (key !== undefined) headersByStep.set(key, contribution.data.header)
     }
     const finalized: ConversationNode[] = []
+    const eventLocations = new Map<number, TrajectoryConversationViewNode['location']>()
     const requests: RequestView[] = []
     const boundaries: { seq: number; time: number }[] = []
     const turnEndings: { turn: number; time: number; error?: string }[] = []
@@ -198,6 +200,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
       }
       if (data.kind === 'node') {
         finalized.push(data.node)
+        eventLocations.set(data.node.seq, contribution.location)
         continue
       }
       if (data.kind === 'assistant') {
@@ -244,6 +247,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder<
     const eventNodes = finalized
     return {
       eventNodes,
+      eventLocations,
       requests,
       callSchemas,
       partial,

+ 36 - 25
packages/client/ui-trajectory/tests/conversation-definitions.spec.ts

@@ -228,21 +228,9 @@ describe('Trajectory conversation Definitions', () => {
   })
 
   it('classifies claimed inbox input as steering and consumes one inherited prompt change', () => {
-    const current = snapshot(assembler([
-      at(1, 'agent/inbox/spliced', {
-        target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }],
-      }),
-      at(2, 'agent/inbox/spliced', {
-        target: 'next-step', start: 0, removedCount: 1, inserted: [],
-      }),
-      at(3, 'user/message', {
-        id: 'm1',
-        role: 'user',
-        content: [{ type: 'text', text: 'steer here' }],
-        source: { kind: 'user' },
-      }),
-      at(4, 'turn/start', { turn: 1 }),
-      at(5, 'request/header', {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'request/header', {
         reason: 'initial',
         header: {
           config: { provider: 'test', model: 'test' },
@@ -250,22 +238,45 @@ describe('Trajectory conversation Definitions', () => {
           tools: [],
         },
       }),
-      at(6, 'step/start', { turn: 1, step: 1 }),
-      at(7, 'assistant/message', {
+      at(3, 'step/start', { turn: 1, step: 1 }),
+      at(4, 'assistant/message', {
         turn: 1,
         step: 1,
         message: assistantMessage('assistant-1', 'first'),
       }),
-      at(8, 'step/end', { turn: 1, step: 1 }),
-      at(9, 'step/start', { turn: 1, step: 2 }),
-      at(10, 'assistant/message', {
-        turn: 1,
-        step: 2,
-        message: assistantMessage('assistant-2', 'second'),
+      at(5, 'step/end', { turn: 1, step: 1 }),
+      at(6, 'agent/inbox/spliced', {
+        target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }],
       }),
-    ]))
+      at(7, 'agent/inbox/spliced', {
+        target: 'next-step', start: 0, removedCount: 1, inserted: [],
+      }),
+      at(8, 'step/start', { turn: 1, step: 2 }),
+    ])
+    value.append(at(9, 'user/message', {
+      id: 'm1',
+      role: 'user',
+      content: [{ type: 'text', text: 'steer here' }],
+      source: { kind: 'user' },
+    }))
+    value.flush()
+
+    const steering = snapshot(value)
+    expect(steering.eventNodes.find(node => node.seq === 9)?.kind).toBe('steering')
+    expect(steering.eventLocations.get(9)).toMatchObject({
+      kind: 'step',
+      turn: { turn: 1 },
+      step: { step: 2 },
+    })
+
+    value.append(at(10, 'assistant/message', {
+      turn: 1,
+      step: 2,
+      message: assistantMessage('assistant-2', 'second'),
+    }))
+    value.flush()
+    const current = snapshot(value)
 
-    expect(current.eventNodes.find(node => node.seq === 3)?.kind).toBe('steering')
     expect(current.requests.map(request => request.purpose === 'assistant'
       ? request.prompt?.system
       : undefined)).toEqual(['system prompt', 'system prompt'])

+ 107 - 1
packages/client/ui-trajectory/tests/layout.spec.tsx

@@ -6,7 +6,7 @@
 import { afterEach, describe, expect, it } from 'vitest'
 import { cleanup, render, screen } from '@testing-library/react'
 import type {
-  ConversationSnapshot, RequestView,
+  ConversationLocation, ConversationSnapshot, RequestView,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
 import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
@@ -239,6 +239,112 @@ describe('deriveTrajectoryLayout', () => {
     ])
   })
 
+  it('places steering in its resolved step instead of the turn-opening Message group', () => {
+    const nodes = [
+      { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'start' }], source: null },
+      {
+        kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
+        blocks: [{ kind: 'text', text: 'first step' }],
+      },
+      {
+        kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
+        content: [{ type: 'text', text: 'change direction' }], source: null,
+      },
+      {
+        kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
+        blocks: [{ kind: 'text', text: 'second step' }],
+      },
+    ] as unknown as ConversationSnapshot['nodes']
+    const data = { get: () => undefined }
+    const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
+    const turn = {
+      turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
+    }
+    const eventLocations = new Map<number, ConversationLocation>([[
+      3,
+      { kind: 'step', turn, step },
+    ]])
+
+    const turns = deriveTrajectoryLayout({
+      nodes,
+      eventLocations,
+      partial: null,
+      runningCalls: [],
+    })
+
+    expect(turns).toHaveLength(1)
+    expect(turns[0]?.groups.map(group => group.title)).toEqual([
+      'Message', 'Step 1', 'Step 2',
+    ])
+    expect(turns[0]?.groups[2]?.cells).toMatchObject([
+      { kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 },
+      { kind: 'message', previewMarkdown: 'second step', sourceSeq: 4 },
+    ])
+  })
+
+  it('keeps a running request boundary after steering input', () => {
+    const nodes = [{
+      kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
+      content: [{ type: 'text', text: 'change direction' }], source: null,
+    }] as unknown as ConversationSnapshot['nodes']
+    const data = { get: () => undefined }
+    const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data }
+    const turn = {
+      turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data,
+    }
+    const eventLocations = new Map<number, ConversationLocation>([[
+      3,
+      { kind: 'step', turn, step },
+    ]])
+
+    const turns = deriveTrajectoryLayout({
+      nodes,
+      eventLocations,
+      partial: null,
+      runningCalls: [],
+      requests: [{
+        purpose: 'assistant',
+        startSeq: 2,
+        turn: 1,
+        step: 2,
+        startedAt: 2_000,
+        completedAt: null,
+        status: 'running',
+      }],
+    })
+
+    expect(turns[0]?.groups[0]?.cells).toMatchObject([
+      { kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 },
+      { kind: 'message', requestOnly: true, sourceSeq: 2 },
+    ])
+  })
+
+  it('uses the following assistant step while a historical window lacks steering Location', () => {
+    const nodes = [
+      {
+        kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000,
+        content: [{ type: 'text', text: 'change direction' }], source: null,
+      },
+      {
+        kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 3,
+        blocks: [{ kind: 'text', text: 'continued' }],
+      },
+    ] as unknown as ConversationSnapshot['nodes']
+
+    const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
+
+    expect(turns[0]).toMatchObject({
+      turn: 2,
+      groups: [{
+        title: 'Step 3',
+        cells: [
+          { kind: 'user', previewMarkdown: 'change direction' },
+          { kind: 'message', previewMarkdown: 'continued' },
+        ],
+      }],
+    })
+  })
+
   it('places standalone compaction chronologically in its own between-turn section', () => {
     const nodes = [
       { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },

+ 7 - 1
packages/client/ui-trajectory/tests/snapshot-builder.spec.ts

@@ -22,7 +22,11 @@ function contribution(
   anchorSeq: number,
   data: TrajectoryContribution,
 ): TrajectoryConversationViewNode {
-  return { key, kind: key, id: key, target: 'trajectory', anchorSeq, data }
+  return {
+    key, kind: key, id: key, target: 'trajectory', anchorSeq,
+    location: { kind: 'session' },
+    data,
+  }
 }
 
 function stepLocation(turn: number, step: number): TrajectoryRequestHeaderState['location'] {
@@ -72,6 +76,7 @@ describe('TrajectorySnapshotBuilder', () => {
         id: '2',
         target: 'trajectory',
         anchorSeq: 2,
+        location: { kind: 'session' },
         data: {
           kind: 'request-header',
           header: {
@@ -89,6 +94,7 @@ describe('TrajectorySnapshotBuilder', () => {
         id: `1:${request.step}`,
         target: 'trajectory' as const,
         anchorSeq: request.startSeq,
+        location: { kind: 'session' as const },
         data: { kind: 'assistant' as const, partial: null, request },
       })),
     ]

+ 37 - 0
packages/client/ui-trajectory/tests/table.spec.tsx

@@ -308,6 +308,43 @@ describe('TrajectoryTable', () => {
     expect(screen.getByText('Request #2')).toBeTruthy()
   })
 
+  it('places the request boundary after leading steering input', () => {
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 2',
+        cells: [{
+          index: 1,
+          kind: 'user',
+          sourceSeq: 3,
+          text: 'change direction',
+          timeSeconds: 0,
+        }, {
+          index: 2,
+          kind: 'message',
+          sourceSeq: 4,
+          text: 'continued',
+          timeSeconds: 1,
+        }],
+      }],
+    }]
+
+    render(<TrajectoryTable
+      turns={turns}
+      requestNumbers={[{
+        seq: 2,
+        turn: 1,
+        step: 2,
+        group: 'Step 2',
+        number: 1,
+      }]}
+      {...FOLD_PROPS}
+    />)
+
+    const request = screen.getByRole('button', { name: 'Request #1' })
+    expect(request.closest('tr')?.getAttribute('aria-label')).toContain('ASSISTANT')
+  })
+
   it('follows appended records only while the ledger is already at the bottom', () => {
     const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
     const tablePane = screen.getByRole('table').parentElement as HTMLElement

+ 1 - 0
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -75,6 +75,7 @@ function historySnapshot(
 ): ConversationSnapshot {
   const trajectory: TrajectorySnapshot = {
     eventNodes: nodes,
+    eventLocations: new Map(),
     requests: [],
     callSchemas: new Map(),
     partial: null,