fix(ui-trajectory): tighten conversation assembly contracts
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user