fix(ui-trajectory): tighten conversation assembly contracts

This commit is contained in:
imccyu
2026-08-10 21:35:57 +08:00
parent b3d3e423f2
commit 62f5d05039
27 changed files with 525 additions and 534 deletions

View File

@@ -84,9 +84,15 @@ describe('tsdown client artifact', () => {
ctx.provide('sessions', { binding: () => undefined })
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
await fiber.await()
const events = ctx.get('conversationEvents') as ConversationEventRegistry
const views = ctx.get('conversationViews') as ConversationViewRegistry
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
expect(events.entries().length).toBeGreaterThan(0)
expect(views.entries()).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('conversation.view')).toHaveLength(0)
expect(events.entries()).toEqual([])
expect(views.entries()).toEqual([])
})
it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => {

View File

@@ -1,129 +0,0 @@
import { describe, expect, it } from 'vitest'
import type {
ConversationContext, ConversationNode, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches,
trajectoryBranchContainsRequest,
} from '../src/client/context-branches.ts'
const checkpoint = {
kind: 'context',
seq: 100,
time: 100,
content: [],
source: { kind: 'plugin', plugin: 'compact' },
provenance: { role: 'inject', label: 'compact' },
form: null,
} as ConversationNode
const abandoned = {
kind: 'assistant',
seq: 20,
time: 20,
turn: 1,
step: 1,
blocks: [{ kind: 'text', text: 'abandoned' }],
} as ConversationNode
const current = {
kind: 'user',
seq: 110,
time: 110,
content: [{ type: 'text', text: 'rewound' }],
source: { kind: 'plugin', plugin: 'rewind' },
} as ConversationNode
function interruptedTool(callId: string): ConversationNode {
return {
kind: 'tool-result',
seq: 19.2,
time: 20,
callId,
call: { name: 'parallel', argsRaw: '{}' },
callTime: 10,
content: [],
isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: null,
resultView: null,
subCalls: [],
}
}
function request(
purpose: RequestView['purpose'],
startSeq: number,
resultSeq?: number,
replacementSeq?: number,
): RequestView {
const base = {
startSeq,
startedAt: startSeq,
completedAt: startSeq + 1,
status: 'complete' as const,
...(resultSeq === undefined ? {} : { resultSeq }),
}
return purpose === 'assistant'
? { ...base, purpose, turn: 1, step: 1 }
: {
...base,
purpose,
turn: 1,
step: 0,
...(replacementSeq === undefined ? {} : { replacementSeq }),
}
}
describe('trajectory context branches', () => {
it('inherits nodes and requests by retained surface position rather than seq cutoff', () => {
const contexts: ConversationContext[] = [
{ id: 0, nodes: [checkpoint, abandoned] },
{
id: 1,
parentId: 0,
origin: 'rewind',
originSeq: 110,
nodes: [checkpoint, current],
},
]
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,
request('assistant', 10, 20),
)).toBe(false)
expect(trajectoryBranchContainsRequest(
successor,
request('compaction', 90, 95, 100),
)).toBe(true)
expect(trajectoryBranchContainsRequest(
successor,
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)
})
it('retains parallel tool interruptions that share one closing boundary', () => {
const branch = deriveTrajectoryContextBranches([{
id: 0,
nodes: [interruptedTool('call-a'), interruptedTool('call-b')],
}])[0]
expect(branch?.nodes.map(node => node.kind === 'tool-result' ? node.callId : undefined))
.toEqual(['call-a', 'call-b'])
})
})

View File

@@ -0,0 +1,276 @@
import type { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type {
ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
import { registerTrajectoryMessageDefinitions } from '../src/client/trajectory-message-definitions.ts'
import { registerTrajectoryRequestHeaderDefinition } from '../src/client/trajectory-request-header-definition.ts'
import { trajectoryViewDefinition } from '../src/client/trajectory-snapshot-builder.ts'
import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool-definition.ts'
const DEFINITIONS: ConversationNodeDefinition[] = []
const registrationContext = {
conversationEvents: {
register: (definition: ConversationNodeDefinition) => {
DEFINITIONS.push(definition)
return () => {}
},
},
} as unknown as Context
registerTrajectoryMessageDefinitions(registrationContext)
registerTrajectoryRequestHeaderDefinition(registrationContext)
registerTrajectoryAssistantDefinition(registrationContext)
registerTrajectoryToolDefinition(registrationContext)
registerTrajectoryCompactionDefinitions(registrationContext)
class TestEventDefinitions {
entries(): readonly ConversationNodeDefinition[] {
return DEFINITIONS
}
fallbackEntry(): undefined {
return undefined
}
}
class TestViewDefinitions {
entries(): readonly ConversationViewDefinition[] {
return [trajectoryViewDefinition]
}
}
function at(
seq: number,
type: string,
data: unknown,
extra: Record<string, unknown> = {},
): ConversationEventInput {
return {
event: {
seq,
time: 1_700_000_000_000 + seq,
type,
data,
...extra,
} as unknown as ConversationEventInput['event'],
view: undefined,
}
}
function assembler(events: readonly ConversationEventInput[]): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(
new TestEventDefinitions(),
new TestViewDefinitions(),
)
value.replaceWindow(events, false)
value.flush()
return value
}
function snapshot(value: ConversationNodeAssembler): TrajectorySnapshot {
const current = value.snapshot('trajectory') as TrajectorySnapshot | undefined
if (current === undefined) throw new Error('trajectory view was not registered')
return current
}
function assistantMessage(id: string, text: string) {
return {
id,
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'test', model: 'test' },
}
}
describe('Trajectory conversation Definitions', () => {
it('assembles streaming usage, preserves retry facts, and materializes interruption', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
}),
at(4, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } },
}),
])
expect(snapshot(value).partial?.blocks).toEqual([{ kind: 'text', text: 'first attempt' }])
expect(snapshot(value).requests).toMatchObject([{
purpose: 'assistant',
status: 'running',
usage: { inputTokens: 10, outputTokens: 3 },
}])
value.append(at(5, 'llm/retry', {
retryId: 'retry-1',
turn: 1,
step: 1,
provider: 'test',
mode: 'normal',
policyKey: 'test-normal',
retry: 1,
maxRetries: 2,
delayMs: 25,
failure: { code: 'TRANSPORT', message: 'temporary failure' },
}))
value.append(at(6, 'assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
}))
value.append(at(7, 'step/end', { turn: 1, step: 1 }))
value.flush()
const settled = snapshot(value)
expect(settled.partial).toBeNull()
expect(settled.eventNodes).toMatchObject([{
kind: 'assistant',
seq: 6.1,
interrupted: true,
blocks: [{ kind: 'text', text: 'second attempt' }],
}])
expect(settled.requests).toMatchObject([{
purpose: 'assistant',
status: 'error',
retry: 1,
maxRetries: 2,
retryDelayMs: 25,
usage: { inputTokens: 10, outputTokens: 3 },
}])
})
it('keeps parallel interrupted roots and nests Code Dispatch results', () => {
const current = snapshot(assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'tool/call', {
turn: 1, step: 1, callId: 'root-a', name: 'code', arguments: '{}',
}),
at(4, 'tool/call', {
turn: 1, step: 1, callId: 'root-b', name: 'parallel', arguments: '{}',
}),
at(5, 'tool/code-dispatch-start', {
rootCallId: 'root-a',
parentCallId: 'root-a',
subCallId: 'child',
name: 'read',
arguments: { path: 'README.md' },
}),
at(6, 'tool/code-dispatch', {
rootCallId: 'root-a',
parentCallId: 'root-a',
subCallId: 'child',
name: 'read',
arguments: { path: 'README.md' },
content: [{ type: 'text', text: 'contents' }],
}),
at(7, 'step/end', { turn: 1, step: 1 }),
]))
const tools = current.eventNodes.filter(node => node.kind === 'tool-result')
expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b'])
expect(tools.find(node => node.callId === 'root-a')?.subCalls).toMatchObject([{
kind: 'tool-result',
callId: 'child',
call: { name: 'read' },
}])
})
it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => {
const current = snapshot(assembler([
at(1, 'compact/start', { compactionId: 'complete', turn: null }),
at(2, 'compact/summary', {
compactionId: 'complete',
turn: null,
summary: 'summary',
provider: 'test',
model: 'test',
maxTokens: 100,
usage: { inputTokens: 20, outputTokens: 5 },
}),
at(3, 'user/message', {
id: 'checkpoint',
role: 'user',
content: [{ type: 'text', text: 'summary checkpoint' }],
source: { kind: 'plugin', plugin: 'compact', compactionId: 'complete' },
}),
at(4, 'compact/end', { compactionId: 'complete', turn: null }),
at(5, 'compact/start', { compactionId: 'orphan', turn: null }),
at(6, 'session/end-seed', {}),
]))
expect(current.requests).toMatchObject([
{
purpose: 'compaction',
startSeq: 1,
status: 'complete',
resultSeq: 2,
replacementSeq: 3,
summary: 'summary',
},
{
purpose: 'compaction',
startSeq: 5,
status: 'error',
completedAt: 1_700_000_000_006,
},
])
})
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', {
reason: 'initial',
header: {
config: { provider: 'test', model: 'test' },
system: 'system prompt',
tools: [],
},
}),
at(6, 'step/start', { turn: 1, step: 1 }),
at(7, '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'),
}),
]))
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'])
expect(current.requests.map(request => request.purpose === 'assistant'
? request.promptChange?.kind
: undefined)).toEqual(['initial', undefined])
})
})

View File

@@ -75,10 +75,8 @@ function historySnapshot(
): ConversationSnapshot {
const trajectory: TrajectorySnapshot = {
eventNodes: nodes,
contexts: [{ id: 0, nodes }],
requests: [],
callSchemas: new Map(),
interruptedNodes: [],
partial: null,
runningCalls: [],
...inspection,
@@ -191,7 +189,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, loadOlder }
return { ctx, slots, fiber, loadOlder, sessionStore }
}
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -294,8 +292,16 @@ describe('plugin registration', () => {
it('fiber disposal removes the tab and leaves chat standing', async () => {
const b = await bench()
const events = b.ctx.get('conversationEvents') as ConversationEventRegistry
const views = b.ctx.get('conversationViews') as ConversationViewRegistry
expect(events.entries().length).toBeGreaterThan(0)
expect(views.entries()).toHaveLength(1)
await b.fiber.dispose()
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
expect(events.entries()).toEqual([])
expect(views.entries()).toEqual([])
})
it('shares one browser-wide duration preference across session injections', async () => {
@@ -315,6 +321,23 @@ describe('plugin registration', () => {
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
})
it('reports whether loading older history changed the Trajectory snapshot', async () => {
const b = await bench()
const entry = b.slots.entries('conversation.view')
.find(candidate => candidate.options.id === 'trajectory')
const injectEntry = entry!.inject as unknown as (
sessionId: SessionId,
) => TrajectoryViewInjected
const injected = injectEntry(SID)
expect(await injected.loadOlder()).toBe(false)
b.loadOlder.mockImplementationOnce(async () => {
b.sessionStore.set(historySnapshot([...NODES]))
})
expect(await injected.loadOlder()).toBe(true)
})
})
describe('tab switching in ConversationRoot', () => {
@@ -1083,7 +1106,7 @@ describe('timeline projection', () => {
})
})
describe('TrajectoryView branches', () => {
describe('TrajectoryView state', () => {
it('persists the duration preference through the runtime snapshot-store seam', () => {
const firstDuration = createTrajectoryDurationStore()
const commonProps = {
@@ -1116,109 +1139,6 @@ describe('TrajectoryView branches', () => {
.toBe('true')
})
it('renders only the selected rewind branch while retaining session-global requests', () => {
const retained = {
kind: 'user',
seq: 1,
time: 1_000,
content: [{ type: 'text', text: 'retained user' }],
source: null,
} as unknown as ConversationSnapshot['nodes'][number]
const abandoned = {
kind: 'assistant',
seq: 3,
time: 3_000,
turn: 1,
step: 1,
blocks: [{ kind: 'text', text: 'abandoned response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const current = {
kind: 'assistant',
seq: 5,
time: 5_000,
turn: 2,
step: 1,
blocks: [{ kind: 'text', text: 'current response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const request = (startSeq: number, turn: number): RequestView => ({
purpose: 'assistant',
startSeq,
turn,
step: 1,
startedAt: startSeq * 1_000,
completedAt: startSeq * 1_000 + 100,
status: 'complete',
})
const store = createSnapshotStore(historySnapshot(
[retained, abandoned, current],
{
eventNodes: [retained, abandoned, current],
contexts: [
{ id: 0, nodes: [retained, abandoned] },
{
id: 1,
parentId: 0,
origin: 'rewind' as const,
originSeq: 4,
nodes: [retained, current],
},
],
requests: [request(2, 1), request(4, 2)],
callSchemas: new Map(),
},
))
const view = render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useSession={bindSnapshotSelector(store)}
loadOlder={vi.fn(() => Promise.resolve(false))}
/>,
)
expect(screen.queryByText('abandoned response')).toBeNull()
expect(screen.getByText('current response')).toBeTruthy()
expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy()
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()}
useSession={bindSnapshotSelector(store)}
loadOlder={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,
@@ -1249,46 +1169,6 @@ describe('TrajectoryView branches', () => {
)).toBeTruthy()
})
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
const retained = {
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'stop the task' }], source: null,
} as unknown as ConversationSnapshot['nodes'][number]
const interruptedAssistant = {
kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'partial response retained' }],
interrupted: true,
} as unknown as ConversationSnapshot['nodes'][number]
const interruptedTool = {
kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call',
call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900,
content: [], isError: true,
error: { name: 'Interrupted', code: 'interrupted' },
callView: null, resultView: null,
} as unknown as ConversationSnapshot['nodes'][number]
const store = createSnapshotStore(historySnapshot(
[retained],
{
eventNodes: [retained],
contexts: [{ id: 0, nodes: [retained] }],
requests: [],
callSchemas: new Map(),
interruptedNodes: [interruptedAssistant, interruptedTool],
},
))
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useSession={bindSnapshotSelector(store)}
loadOlder={vi.fn(() => Promise.resolve(false))}
/>,
)
expect(screen.getByText('partial response retained')).toBeTruthy()
expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy()
})
})
describe('node half', () => {