Merge remote-tracking branch 'github/master' into xtr/trajectory-timeline-polish
# Conflicts: # packages/client/ui-trajectory/src/client/timeline.ts # packages/client/ui-trajectory/tests/views.spec.tsx
This commit is contained in:
@@ -47,6 +47,7 @@ describe('tsdown client artifact', () => {
|
||||
const modules = new Map<string, unknown>([
|
||||
['react', await import('react')],
|
||||
['react/jsx-runtime', await import('react/jsx-runtime')],
|
||||
['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')],
|
||||
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
|
||||
])
|
||||
const surface = handoff!.factory((spec) => {
|
||||
|
||||
@@ -38,17 +38,22 @@ function request(
|
||||
resultSeq?: number,
|
||||
replacementSeq?: number,
|
||||
): RequestView {
|
||||
return {
|
||||
purpose,
|
||||
const base = {
|
||||
startSeq,
|
||||
turn: 1,
|
||||
step: purpose === 'assistant' ? 1 : 0,
|
||||
startedAt: startSeq,
|
||||
completedAt: startSeq + 1,
|
||||
status: 'complete',
|
||||
status: 'complete' as const,
|
||||
...(resultSeq === undefined ? {} : { resultSeq }),
|
||||
...(replacementSeq === undefined ? {} : { replacementSeq }),
|
||||
}
|
||||
return purpose === 'assistant'
|
||||
? { ...base, purpose, turn: 1, step: 1 }
|
||||
: {
|
||||
...base,
|
||||
purpose,
|
||||
turn: 1,
|
||||
step: 0,
|
||||
...(replacementSeq === undefined ? {} : { replacementSeq }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('trajectory context branches', () => {
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, RequestView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
|
||||
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
|
||||
@@ -161,6 +163,49 @@ describe('deriveTrajectoryLayout', () => {
|
||||
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
|
||||
})
|
||||
|
||||
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 },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'before compaction' }],
|
||||
},
|
||||
{ kind: 'user', seq: 5, time: 5_000, content: [{ type: 'text', text: 'second' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'after compaction' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const compaction: RequestView = {
|
||||
purpose: 'compaction',
|
||||
startSeq: 3,
|
||||
turn: null,
|
||||
step: 0,
|
||||
startedAt: 3_000,
|
||||
completedAt: 4_000,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'standalone summary' }],
|
||||
}
|
||||
|
||||
const turns = deriveTrajectoryLayout({
|
||||
codeDispatches: new Map(),
|
||||
nodes,
|
||||
partial: null,
|
||||
runningCalls: [],
|
||||
requests: [compaction],
|
||||
})
|
||||
|
||||
expect(turns.map(turn => turn.turn)).toEqual([1, null, 2])
|
||||
expect(turns[1]?.groups).toMatchObject([{
|
||||
title: 'Compaction 3',
|
||||
cells: [{
|
||||
kind: 'compacted',
|
||||
sourceSeq: 3,
|
||||
text: 'standalone summary',
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('keeps usage and a meaningful summary when assistant has no text block', () => {
|
||||
const nodes = [
|
||||
{
|
||||
@@ -195,7 +240,7 @@ describe('deriveTrajectoryLayout', () => {
|
||||
expect(message?.thinkingDetail).toBe(thinking)
|
||||
})
|
||||
|
||||
it('advances the duration cursor over context nodes', () => {
|
||||
it('advances the duration cursor over context and compaction nodes', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
{
|
||||
@@ -211,17 +256,21 @@ describe('deriveTrajectoryLayout', () => {
|
||||
kind: 'context', seq: 4, time: 9_000,
|
||||
content: [{ type: 'text', text: 'extra' }], source: null,
|
||||
},
|
||||
// A landed compaction renders no cell, but is still a real log position,
|
||||
// so it moves the cursor after the visible context row.
|
||||
{ kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' },
|
||||
{
|
||||
kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0,
|
||||
kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'done' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups
|
||||
.flatMap(g => g.cells)
|
||||
.find(c => c.kind === 'message' && c.text === 'done')
|
||||
// From context at 9s, not from the earlier user/tool surfaces.
|
||||
expect(message?.timeSeconds).toBe(1)
|
||||
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
|
||||
const message = cells.find(c => c.kind === 'message' && c.text === 'done')
|
||||
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
|
||||
expect(message?.timeSeconds).toBe(0.5)
|
||||
// Context remains inspectable in trajectory; the Chat marker is not duplicated.
|
||||
expect(cells.map(cell => cell.kind)).toEqual(['user', 'message', 'tool', 'context', 'message'])
|
||||
})
|
||||
|
||||
it('uses the recorded step start for assistant duration when timing exists', () => {
|
||||
|
||||
@@ -124,7 +124,7 @@ function emptySessions() {
|
||||
|
||||
function emptyWorkspaces() {
|
||||
const store = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: undefined,
|
||||
})
|
||||
return bindSnapshotSelector(store)
|
||||
@@ -317,6 +317,105 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
|
||||
})
|
||||
|
||||
it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'before' }],
|
||||
},
|
||||
{ kind: 'user', seq: 5, time: 5_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'after' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const compaction: RequestView = {
|
||||
purpose: 'compaction',
|
||||
startSeq: 3,
|
||||
turn: null,
|
||||
step: 0,
|
||||
startedAt: 3_000,
|
||||
completedAt: 4_000,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'standalone summary' }],
|
||||
}
|
||||
const b = await bench(historySnapshot(nodes, { requests: [compaction] }))
|
||||
const view = mount(b.slots, nodes)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
|
||||
expect(screen.getByText('Between turns')).toBeTruthy()
|
||||
expect(view.container.textContent).not.toContain('Turn null')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request #2 · Compaction' }))
|
||||
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
|
||||
expect(view.container.textContent).not.toContain('Turn null')
|
||||
})
|
||||
|
||||
it('activates only the selected standalone compaction section', async () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'before first compaction' }],
|
||||
},
|
||||
{ kind: 'user', seq: 5, time: 5_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'between compactions' }],
|
||||
},
|
||||
{ kind: 'user', seq: 9, time: 9_000, content: [], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 10, time: 10_000, turn: 3, step: 1,
|
||||
blocks: [{ kind: 'text', text: 'after second compaction' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const compactions: RequestView[] = [
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 3,
|
||||
turn: null,
|
||||
step: 0,
|
||||
startedAt: 3_000,
|
||||
completedAt: 4_000,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'first standalone summary' }],
|
||||
},
|
||||
{
|
||||
purpose: 'compaction',
|
||||
startSeq: 7,
|
||||
turn: null,
|
||||
step: 0,
|
||||
startedAt: 7_000,
|
||||
completedAt: 8_000,
|
||||
status: 'complete',
|
||||
summary: [{ type: 'text', text: 'second standalone summary' }],
|
||||
},
|
||||
]
|
||||
const b = await bench(historySnapshot(nodes, { requests: compactions }))
|
||||
mount(b.slots, nodes)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
|
||||
const firstRequest = screen.getByRole('button', { name: 'Request #2 · Compaction' })
|
||||
const secondRequest = screen.getByRole('button', { name: 'Request #4 · Compaction' })
|
||||
const firstSection = firstRequest.closest('tr')?.querySelector('span')
|
||||
const secondSection = secondRequest.closest('tr')?.querySelector('span')
|
||||
expect(firstSection?.textContent).toBe('Between turns')
|
||||
expect(secondSection?.textContent).toBe('Between turns')
|
||||
|
||||
fireEvent.click(firstRequest)
|
||||
expect(firstSection?.className).toMatch(/turnLabelActive/)
|
||||
expect(secondSection?.className).not.toMatch(/turnLabelActive/)
|
||||
expect(screen.getByText('Request #2')).toBeTruthy()
|
||||
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
|
||||
|
||||
fireEvent.click(secondRequest)
|
||||
expect(firstSection?.className).not.toMatch(/turnLabelActive/)
|
||||
expect(secondSection?.className).toMatch(/turnLabelActive/)
|
||||
expect(screen.getByText('Request #4')).toBeTruthy()
|
||||
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
@@ -678,6 +777,44 @@ describe('timeline projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('projects between-turn compaction without inventing a turn boundary', () => {
|
||||
const withStandaloneCompaction = [
|
||||
{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{ index: 1, kind: 'message', text: 'before', timeSeconds: 0 }],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: null,
|
||||
groups: [{
|
||||
title: 'Compaction 3',
|
||||
cells: [{ index: 2, kind: 'compacted', text: 'summary', timeSeconds: 0 }],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{ index: 3, kind: 'message', text: 'after', timeSeconds: 0 }],
|
||||
}],
|
||||
},
|
||||
] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
expect(deriveTrajectoryTimeline(withStandaloneCompaction)).toMatchObject({
|
||||
spans: [
|
||||
{ index: 1, start: 0, end: 1 },
|
||||
{ index: 2, start: 1, end: 2 },
|
||||
{ index: 3, start: 2, end: 3 },
|
||||
],
|
||||
turnBoundaries: [
|
||||
{ turn: 1, time: 0 },
|
||||
{ turn: 2, time: 2 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('empty inputs produce no model and the standalone view reports its empty form', () => {
|
||||
expect(deriveTrajectoryTimeline([])).toBeNull()
|
||||
render(createElement(
|
||||
|
||||
Reference in New Issue
Block a user