fix(trajectory): preserve state across history prepends
This commit is contained in:
@@ -71,6 +71,7 @@ describe('trajectory context branches', () => {
|
||||
const branches = deriveTrajectoryContextBranches(contexts)
|
||||
const successor = branches[1]!
|
||||
|
||||
expect(successor.key).toBe('rewind:110')
|
||||
expect(successor.nodes.map(node => node.seq)).toEqual([110])
|
||||
expect(trajectoryBranchContainsRequest(
|
||||
successor,
|
||||
@@ -85,4 +86,15 @@ describe('trajectory context branches', () => {
|
||||
request('assistant', 111),
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps branch identity when prepended generations shift local ids', () => {
|
||||
const branch = (id: number) => deriveTrajectoryContextBranches([{
|
||||
id,
|
||||
origin: 'rewind',
|
||||
originSeq: 110,
|
||||
nodes: [current],
|
||||
}])[0]
|
||||
|
||||
expect(branch(1)?.key).toBe(branch(9)?.key)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -140,6 +140,34 @@ describe('deriveTrajectoryLayout', () => {
|
||||
expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces a running-call placeholder with the matching streamed tool call', () => {
|
||||
const partial = {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
blocks: [{
|
||||
kind: 'tool-call' as const,
|
||||
callId: 'c1',
|
||||
name: 'bash',
|
||||
argsRaw: '{"command":"pwd"}',
|
||||
}],
|
||||
}
|
||||
const base = deriveTrajectoryLayout({
|
||||
codeDispatches: new Map(),
|
||||
nodes: [],
|
||||
partial: { ...partial, blocks: [] },
|
||||
runningCalls: [{
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
||||
turn: 1, step: 1, time: 9_000, callView: null,
|
||||
}],
|
||||
})
|
||||
|
||||
const streamed = appendTrajectoryPartialLayout(base, partial, 1)
|
||||
const cells = streamed[0]?.groups[0]?.cells ?? []
|
||||
|
||||
expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool'])
|
||||
expect(cells.filter(cell => cell.callId === 'c1')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('omits duration when node times are missing instead of rendering NaN', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
|
||||
@@ -60,7 +60,7 @@ const TURNS: readonly TrajectoryTurnModel[] = [{
|
||||
const FOLD_PROPS = {
|
||||
collapsedTurns: new Set<number>(),
|
||||
onToggleTurn: () => {},
|
||||
collapsedAssistants: new Set<number>(),
|
||||
collapsedAssistants: new Set<string>(),
|
||||
onToggleAssistant: () => {},
|
||||
}
|
||||
|
||||
@@ -189,6 +189,93 @@ describe('TrajectoryTable', () => {
|
||||
expect(onClearSelection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the selected record when older rows shift projection indexes', () => {
|
||||
const tail = (index: number): TrajectoryTurnModel => ({
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index,
|
||||
kind: 'message',
|
||||
sourceSeq: 100,
|
||||
text: 'selected tail response',
|
||||
outputDetail: 'selected tail response detail',
|
||||
timeSeconds: 1,
|
||||
}],
|
||||
}],
|
||||
})
|
||||
const view = render(
|
||||
<TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('row', { name: /selected tail response/ }))
|
||||
|
||||
view.rerender(
|
||||
<TrajectoryTable
|
||||
turns={[{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Message',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'user',
|
||||
sourceSeq: 1,
|
||||
text: 'older prompt',
|
||||
timeSeconds: 0,
|
||||
}],
|
||||
}],
|
||||
}, tail(2)]}
|
||||
{...FOLD_PROPS}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('row', { name: /selected tail response/ })
|
||||
.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('selected tail response detail')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps a selected request when prepending changes its display number', () => {
|
||||
const tail = (index: number): TrajectoryTurnModel => ({
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index,
|
||||
kind: 'message',
|
||||
sourceSeq: 100,
|
||||
text: 'tail response',
|
||||
timeSeconds: 1,
|
||||
}],
|
||||
}],
|
||||
})
|
||||
const view = render(
|
||||
<TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
|
||||
|
||||
view.rerender(
|
||||
<TrajectoryTable
|
||||
turns={[{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'message',
|
||||
sourceSeq: 1,
|
||||
text: 'older response',
|
||||
timeSeconds: 1,
|
||||
}],
|
||||
}],
|
||||
}, tail(2)]}
|
||||
{...FOLD_PROPS}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Request #2' })
|
||||
.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(screen.getByText('Request #2')).toBeTruthy()
|
||||
})
|
||||
|
||||
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
|
||||
@@ -339,6 +426,55 @@ describe('TrajectoryTable', () => {
|
||||
expect(screen.queryByText('Context 1')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the virtual tail reachable with collapsed-summary row heights', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
})
|
||||
const turns: readonly TrajectoryTurnModel[] = Array.from(
|
||||
{ length: 101 },
|
||||
(_, index) => ({
|
||||
turn: index + 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{
|
||||
index: index * 2 + 1,
|
||||
kind: 'message' as const,
|
||||
sourceSeq: index * 2 + 1,
|
||||
text: `Message ${index + 1}`,
|
||||
timeSeconds: 1,
|
||||
},
|
||||
{
|
||||
index: index * 2 + 2,
|
||||
kind: 'tool' as const,
|
||||
callId: `call-${index + 1}`,
|
||||
text: `Tool ${index + 1}`,
|
||||
timeSeconds: 1,
|
||||
},
|
||||
],
|
||||
}],
|
||||
}),
|
||||
)
|
||||
const collapsedTurns = new Set(turns.flatMap(turn =>
|
||||
turn.turn === null ? [] : [turn.turn]))
|
||||
const view = render(
|
||||
<TrajectoryTable
|
||||
turns={turns}
|
||||
{...FOLD_PROPS}
|
||||
collapsedTurns={collapsedTurns}
|
||||
/>,
|
||||
)
|
||||
const tablePane = screen.getByRole('table').parentElement as HTMLElement
|
||||
tablePane.scrollTop = 5_000
|
||||
fireEvent.scroll(tablePane)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelector('tr[data-virtual-position="201"]')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps running and failure semantics distinct from record roles', () => {
|
||||
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
|
||||
|
||||
@@ -73,6 +73,7 @@ function historySnapshot(
|
||||
state: 'ready',
|
||||
error: null,
|
||||
hasMore: false,
|
||||
baseSeq: nodes[0]?.seq ?? 0,
|
||||
inspection: {
|
||||
eventNodes: nodes,
|
||||
contexts: [{ id: 0, nodes }],
|
||||
@@ -1153,6 +1154,74 @@ describe('TrajectoryView branches', () => {
|
||||
expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not remount the ledger when prepending shifts a rewind generation id', () => {
|
||||
const current = {
|
||||
kind: 'assistant',
|
||||
seq: 5,
|
||||
time: 5_000,
|
||||
turn: 2,
|
||||
step: 1,
|
||||
blocks: [{ kind: 'text', text: 'stable rewind response' }],
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const snapshot = (id: number) => historySnapshot([current], {
|
||||
contexts: [{
|
||||
id,
|
||||
origin: 'rewind' as const,
|
||||
originSeq: 4,
|
||||
nodes: [current],
|
||||
}],
|
||||
})
|
||||
const store = createSnapshotStore(snapshot(1))
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
const row = screen.getByRole('row', { name: /stable rewind response/ })
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
|
||||
act(() => { store.set(snapshot(2)) })
|
||||
|
||||
expect(screen.getByRole('row', { name: /stable rewind response/ })
|
||||
.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('keeps ledger and timeline selection on the same event after prepend', () => {
|
||||
const older = {
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'older prompt' }], source: null,
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const current = {
|
||||
kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'selected current response' }],
|
||||
} as unknown as ConversationSnapshot['nodes'][number]
|
||||
const store = createSnapshotStore(historySnapshot([current]))
|
||||
const view = render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
|
||||
|
||||
act(() => { store.set(historySnapshot([older, current])) })
|
||||
|
||||
const row = screen.getByRole('row', { name: /selected current response/ })
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
const currentIndex = row.getAttribute('data-record-index')
|
||||
expect(view.container.querySelector(
|
||||
`[data-timeline-record-index="${currentIndex}"][data-current="true"]`,
|
||||
)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
|
||||
const retained = {
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
|
||||
Reference in New Issue
Block a user