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

This commit is contained in:
imccyu
2026-08-11 01:06:11 +08:00
parent 98a4cc6569
commit d66e693c63
11 changed files with 303 additions and 47 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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,

View File

@@ -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'])

View File

@@ -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 },

View File

@@ -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 },
})),
]

View File

@@ -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

View File

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