Assembler cases cover a claim in either registration order and on live append, other targets and target-less Definitions, the skipped fallback, and both dispatch failures. Chat grouping cases split the step run by position regardless of kind and regroup on a position change; the late reply produces one Chat Node through a real assembler.
2019 lines
79 KiB
TypeScript
2019 lines
79 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import type {
|
|
SessionEventLike, SessionEventLikeEntry, SessionLiveEventEntry,
|
|
} from '@deepseek-ai/dsh-api-session-controller/client'
|
|
import { LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
|
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
|
import {
|
|
ConversationLocationIndex,
|
|
ConversationNodeAssembler as RuntimeConversationNodeAssembler,
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import type {
|
|
ConversationMatch, ConversationNodeContext,
|
|
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
interface ScopeProbeStepData {
|
|
readonly value: number
|
|
}
|
|
|
|
interface ScopeProbeTurnData {
|
|
readonly valueSeenFromStep: number
|
|
}
|
|
|
|
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
|
interface ConversationStepDataMap {
|
|
'scope-probe': ScopeProbeStepData
|
|
}
|
|
|
|
interface ConversationTurnDataMap {
|
|
'scope-probe': ScopeProbeTurnData
|
|
}
|
|
}
|
|
|
|
interface TestSnapshot {
|
|
readonly order: readonly string[]
|
|
readonly nodes: ReadonlyMap<string, ConversationViewNode>
|
|
}
|
|
|
|
class TestEventDefinitions {
|
|
readonly definitions: readonly ConversationNodeDefinition[]
|
|
readonly fallback: ConversationNodeDefinition | undefined
|
|
|
|
constructor(
|
|
definitions: readonly ConversationNodeDefinition[],
|
|
fallback?: ConversationNodeDefinition,
|
|
) {
|
|
this.definitions = definitions
|
|
this.fallback = fallback
|
|
}
|
|
|
|
entries(): readonly ConversationNodeDefinition[] {
|
|
return this.definitions
|
|
}
|
|
|
|
fallbackEntry(): ConversationNodeDefinition | undefined {
|
|
return this.fallback
|
|
}
|
|
}
|
|
|
|
class TestViewDefinitions {
|
|
constructor(readonly definitions: readonly ConversationViewDefinition[]) {}
|
|
|
|
entries(): readonly ConversationViewDefinition[] {
|
|
return this.definitions
|
|
}
|
|
}
|
|
|
|
class ConversationNodeAssembler extends RuntimeConversationNodeAssembler {
|
|
constructor(events: TestEventDefinitions, views: TestViewDefinitions) {
|
|
super(events, views)
|
|
for (const view of views.entries()) this.activateTarget(view.target)
|
|
}
|
|
}
|
|
|
|
function testView(
|
|
apply = vi.fn(),
|
|
): ConversationViewDefinition<ConversationViewNode, TestSnapshot> {
|
|
return {
|
|
target: 'test',
|
|
create: () => {
|
|
let current: TestSnapshot = { order: [], nodes: new Map() }
|
|
return {
|
|
empty: current,
|
|
replace: ({ nodes }) => {
|
|
current = { order: nodes.map(node => node.key), nodes: new Map(nodes.map(node => [node.key, node])) }
|
|
return current
|
|
},
|
|
apply: ({ upserts }) => {
|
|
apply(upserts)
|
|
const nodes = new Map(current.nodes)
|
|
const order = [...current.order]
|
|
for (const node of upserts) {
|
|
if (!nodes.has(node.key)) order.push(node.key)
|
|
nodes.set(node.key, node)
|
|
}
|
|
current = { order, nodes }
|
|
return current
|
|
},
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
function trackedView(target: string) {
|
|
const replace = vi.fn(({ nodes }: { readonly nodes: readonly ConversationViewNode[] }) => nodes)
|
|
const apply = vi.fn(({ upserts }: { readonly upserts: readonly ConversationViewNode[] }) => upserts)
|
|
const create = vi.fn(() => ({
|
|
empty: [] as readonly ConversationViewNode[],
|
|
replace,
|
|
apply,
|
|
}))
|
|
const definition: ConversationViewDefinition<ConversationViewNode, readonly ConversationViewNode[]> = {
|
|
target,
|
|
create,
|
|
}
|
|
return { definition, create, replace, apply }
|
|
}
|
|
|
|
function at(seq: SessionSeq, type: string, data: unknown): SessionEvent {
|
|
return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent
|
|
}
|
|
|
|
function input(event: SessionEvent): SessionLiveEventEntry {
|
|
return { type: 'event', event }
|
|
}
|
|
|
|
function transientChunk(
|
|
seq: number,
|
|
turn: number,
|
|
step: number,
|
|
chunk: StreamChunk,
|
|
): SessionEventLikeEntry {
|
|
return {
|
|
type: 'transient',
|
|
event: {
|
|
type: 'assistant/live-chunk',
|
|
seq,
|
|
time: 1_700_000_000_000 + seq,
|
|
data: { attemptId: LlmAttemptId('test-attempt'), turn, step, chunk },
|
|
},
|
|
}
|
|
}
|
|
|
|
function testSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined {
|
|
return assembler.snapshot('test') as TestSnapshot | undefined
|
|
}
|
|
|
|
function node(
|
|
context: Parameters<NonNullable<ConversationNodeDefinition['buildViewNode']>>[0],
|
|
data: unknown,
|
|
): ConversationViewNode {
|
|
return {
|
|
key: context.key,
|
|
kind: context.kind,
|
|
id: context.id,
|
|
target: 'test',
|
|
data,
|
|
}
|
|
}
|
|
|
|
function fallbackDefinition(start: () => string): ConversationNodeDefinition<string> {
|
|
return {
|
|
kind: 'fallback',
|
|
target: 'test',
|
|
match: event => ({ id: String(event.seq), role: 'start' }),
|
|
start,
|
|
update: context => context.state,
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
}
|
|
|
|
describe('ConversationNodeAssembler', () => {
|
|
it('indexes each appended input once and schedules each Context once per flush', () => {
|
|
const updates = vi.fn()
|
|
const definitions: ConversationNodeDefinition<{ count: number }>[] = ['copy', 'mutable'].map(kind => ({
|
|
kind,
|
|
target: 'test',
|
|
match: event => ({ id: 'one', role: event.seq === 1 ? 'start' : 'update' }),
|
|
start: () => ({ count: 0 }),
|
|
update: (context) => {
|
|
updates()
|
|
if (kind === 'copy') return { count: context.state.count + 1 }
|
|
context.state.count++
|
|
return context.state
|
|
},
|
|
buildViewNode: context => node(context, context.state?.count),
|
|
}))
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions(definitions), new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'count/start', {}))], false)
|
|
assembler.flush()
|
|
// Count index writes separately from the required per-event State updates.
|
|
const owners = vi.spyOn(Reflect.get(assembler, 'contextsBySeq') as Map<number, unknown>, 'set')
|
|
const dirty = vi.spyOn(Reflect.get(assembler, 'dirty') as Set<unknown>, 'add')
|
|
const targets = vi.spyOn(Reflect.get(assembler, 'dirtyByTarget') as Map<string, unknown>, 'set')
|
|
try {
|
|
for (const batch of [0, 1]) {
|
|
for (let offset = 0; offset < 64; offset++) {
|
|
assembler.append(input(at(SessionSeq(2 + batch * 64 + offset), 'count/update', {})))
|
|
}
|
|
expect(owners).toHaveBeenCalledTimes(64)
|
|
expect(dirty).toHaveBeenCalledTimes(2)
|
|
expect(targets).toHaveBeenCalledTimes(1)
|
|
expect(updates).toHaveBeenCalledTimes((batch + 1) * 128)
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)!.nodes.values()].map(value => value.data))
|
|
.toEqual([(batch + 1) * 64, (batch + 1) * 64])
|
|
owners.mockClear()
|
|
dirty.mockClear()
|
|
targets.mockClear()
|
|
}
|
|
} finally {
|
|
owners.mockRestore()
|
|
dirty.mockRestore()
|
|
targets.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('resolves nonconsecutive Steps without searching the Turn timeline on each delta', () => {
|
|
const index = new ConversationLocationIndex()
|
|
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 4 })
|
|
const firstStep = at(SessionSeq(2), 'step/start', { turn: 4, step: 7 })
|
|
const lastStep = at(SessionSeq(3), 'step/start', { turn: 4, step: 200 })
|
|
index.rebuild([input(turnStart), input(firstStep), input(lastStep)])
|
|
const turn = index.snapshot().turns.get(4)!
|
|
const find = vi.spyOn(turn.steps, 'find')
|
|
const turnIndex = vi.spyOn(Reflect.get(index, 'seqsByTurn') as Map<number, unknown>, 'set')
|
|
try {
|
|
for (const [offset, step] of [7, 200, 7].entries()) {
|
|
const { event } = transientChunk(3 + (offset + 1) / 10, 4, step, { type: 'text-delta', index: 0, text: 'a' })
|
|
index.appendNonBoundary(event)
|
|
expect(index.locationOf(event)).toEqual({
|
|
kind: 'step', turn, step: turn.steps[step === 7 ? 0 : 1],
|
|
})
|
|
}
|
|
expect(find).not.toHaveBeenCalled()
|
|
expect(turnIndex).not.toHaveBeenCalled()
|
|
} finally {
|
|
find.mockRestore()
|
|
turnIndex.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('resolves current Step references after closure, prepend and window replacement', () => {
|
|
const index = new ConversationLocationIndex()
|
|
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 })
|
|
const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 9 })
|
|
const stepEnd = at(SessionSeq(3), 'step/end', { turn: 1, step: 9 })
|
|
const { event } = transientChunk(2.5, 1, 9, { type: 'text-delta', index: 0, text: 'a' })
|
|
index.rebuild([input(turnStart), input(stepStart)])
|
|
index.appendNonBoundary(event)
|
|
const open = index.locationOf(event)
|
|
index.appendBoundary(stepEnd)
|
|
const closed = index.locationOf(event)
|
|
expect(closed).toMatchObject({ kind: 'step', step: { status: 'closed', end: stepEnd } })
|
|
expect(closed).not.toEqual(open)
|
|
|
|
index.rebuild([input(stepEnd)])
|
|
index.appendNonBoundary(event)
|
|
expect(index.locationOf(event)).toMatchObject({ kind: 'step', step: { start: undefined, end: stepEnd } })
|
|
index.rebuild([input(turnStart), input(stepStart), input(stepEnd)])
|
|
index.appendNonBoundary(event)
|
|
expect(index.locationOf(event)).toMatchObject({ kind: 'step', step: { start: stepStart, end: stepEnd } })
|
|
|
|
index.rebuild([input(turnStart)])
|
|
index.appendNonBoundary(event)
|
|
expect(index.locationOf(event)).toEqual({ kind: 'turn', turn: index.snapshot().turns.get(1) })
|
|
index.rebuild([])
|
|
index.appendNonBoundary(event)
|
|
expect(index.locationOf(event)).toEqual({ kind: 'unresolved' })
|
|
index.appendBoundary(turnStart)
|
|
index.appendBoundary(stepStart)
|
|
expect(index.locationOf(event)).toMatchObject({ kind: 'step', step: { start: stepStart, end: undefined } })
|
|
})
|
|
|
|
it('reports a repaired Location while preserving the previously returned value', () => {
|
|
const index = new ConversationLocationIndex()
|
|
const turnStart = input(at(SessionSeq(1), 'turn/start', { turn: 1 }))
|
|
const stepStart = input(at(SessionSeq(2), 'step/start', { turn: 1, step: 9 }))
|
|
const delta = transientChunk(2.5, 1, 9, { type: 'text-delta', index: 0, text: 'a' })
|
|
index.rebuild([turnStart])
|
|
index.appendNonBoundary(delta.event)
|
|
const previous = index.locationOf(delta.event)
|
|
expect(previous.kind).toBe('turn')
|
|
|
|
const changed = index.rebuild([turnStart, stepStart, delta])
|
|
expect(changed.has(delta.event.seq)).toBe(true)
|
|
expect(index.locationOf(delta.event)).toMatchObject({ kind: 'step', step: { step: 9 } })
|
|
expect(previous.kind).toBe('turn')
|
|
expect(previous).not.toBe(index.locationOf(delta.event))
|
|
})
|
|
|
|
it('keeps equally numbered Steps in different Turns independent', () => {
|
|
const index = new ConversationLocationIndex()
|
|
index.rebuild([
|
|
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
|
|
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 3 })),
|
|
input(at(SessionSeq(3), 'turn/start', { turn: 2 })),
|
|
input(at(SessionSeq(4), 'step/start', { turn: 2, step: 3 })),
|
|
])
|
|
for (const turn of [1, 2]) {
|
|
const { event } = transientChunk(4 + turn / 10, turn, 3, { type: 'text-delta', index: 0, text: 'a' })
|
|
index.appendNonBoundary(event)
|
|
const location = index.locationOf(event)
|
|
expect(location).toMatchObject({ kind: 'step', step: { turn, step: 3 } })
|
|
if (location.kind !== 'step') throw new Error('expected Step location')
|
|
expect(location.step).toBe(index.snapshot().turns.get(turn)?.steps[0])
|
|
}
|
|
})
|
|
|
|
it('reports boundary changes only when the owning Turn location changes', () => {
|
|
const index = new ConversationLocationIndex()
|
|
const boundaries = [
|
|
at(SessionSeq(1), 'turn/start', { turn: 1 }),
|
|
at(SessionSeq(2), 'step/start', { turn: 1, step: 1 }),
|
|
at(SessionSeq(3), 'step/end', { turn: 1, step: 1 }),
|
|
at(SessionSeq(4), 'turn/end', { turn: 1 }),
|
|
]
|
|
for (const boundary of boundaries) {
|
|
index.appendBoundary(boundary)
|
|
expect(index.takeChangedTurns()).toEqual([1])
|
|
const turn = index.snapshot().turns.get(1)
|
|
expect(index.appendBoundary(boundary)).toEqual(new Set())
|
|
expect(index.snapshot().turns.get(1)).toBe(turn)
|
|
expect(index.takeChangedTurns()).toEqual([])
|
|
}
|
|
})
|
|
|
|
it('publishes Location data through stable per-key sources', () => {
|
|
const index = new ConversationLocationIndex()
|
|
const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 })
|
|
const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })
|
|
index.rebuild([input(turnStart), input(stepStart)])
|
|
expect(index.takeChangedTurns()).toEqual([1])
|
|
expect(index.takeChangedTurns()).toEqual([])
|
|
const location = index.locationOf(stepStart)
|
|
if (location.kind !== 'step') throw new Error('scope probe requires a Step Location')
|
|
const source = location.step.data.source('scope-probe')
|
|
const listener = vi.fn()
|
|
source.subscribe(listener)
|
|
const initial = { value: 1 }
|
|
|
|
expect(location.step.data.source('scope-probe')).toBe(source)
|
|
expect(source.getSnapshot()).toBeUndefined()
|
|
expect(index.replaceData([{
|
|
owner: 'scope-probe:1:1',
|
|
data: { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: initial },
|
|
}])).toBe(true)
|
|
expect(source.getSnapshot()).toBe(initial)
|
|
expect(listener).not.toHaveBeenCalled()
|
|
expect(index.takeChangedTurns()).toEqual([1])
|
|
|
|
index.publishData()
|
|
expect(listener).toHaveBeenCalledOnce()
|
|
listener.mockClear()
|
|
|
|
index.replaceData([])
|
|
index.replaceData([{
|
|
owner: 'scope-probe:1:1',
|
|
data: { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: initial },
|
|
}])
|
|
index.publishData()
|
|
expect(listener).not.toHaveBeenCalled()
|
|
expect(index.takeChangedTurns()).toEqual([1])
|
|
|
|
const changed = { value: 2 }
|
|
index.replaceData([{
|
|
owner: 'scope-probe:1:1',
|
|
data: { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: changed },
|
|
}])
|
|
index.publishData()
|
|
expect(source.getSnapshot()).toBe(changed)
|
|
expect(listener).toHaveBeenCalledOnce()
|
|
|
|
index.replaceData([])
|
|
index.publishData()
|
|
expect(source.getSnapshot()).toBeUndefined()
|
|
expect(listener).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('reports a replacement only when an active target has a registered builder', () => {
|
|
const assembler = new RuntimeConversationNodeAssembler(
|
|
new TestEventDefinitions([]),
|
|
new TestViewDefinitions([]),
|
|
)
|
|
|
|
expect(assembler.activateTarget('registered-later')).toBe(false)
|
|
assembler.replaceWindow([], false)
|
|
|
|
expect(assembler.flush()).toBe(false)
|
|
})
|
|
|
|
it('updates only active targets and never deactivates one after first use', () => {
|
|
type State = { readonly updates: number }
|
|
const definition = (
|
|
target: string,
|
|
buildViewNode: NonNullable<ConversationNodeDefinition<State>['buildViewNode']>,
|
|
): ConversationNodeDefinition<State> => ({
|
|
kind: `active-${target}`,
|
|
target,
|
|
match: (event) => {
|
|
const type = event.type as string
|
|
if (type === 'active/start') return { id: 'one', role: 'start' }
|
|
if (type === 'active/update') return { id: 'one', role: 'update' }
|
|
return null
|
|
},
|
|
start: () => ({ updates: 0 }),
|
|
update: context => ({ updates: context.state.updates + 1 }),
|
|
buildViewNode,
|
|
})
|
|
const chat = trackedView('chat')
|
|
const trajectory = trackedView('trajectory')
|
|
const build = (target: string) => vi.fn((context: ConversationNodeContext<State>): ConversationViewNode => ({
|
|
key: context.key,
|
|
kind: context.kind,
|
|
id: context.id,
|
|
target,
|
|
data: context.state,
|
|
}))
|
|
const buildChat = build('chat')
|
|
const buildTrajectory = build('trajectory')
|
|
const assembler = new RuntimeConversationNodeAssembler(
|
|
new TestEventDefinitions([
|
|
definition('chat', buildChat),
|
|
definition('trajectory', buildTrajectory),
|
|
]),
|
|
new TestViewDefinitions([chat.definition, trajectory.definition]),
|
|
)
|
|
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'active/start', {}))], false)
|
|
expect(assembler.flush()).toBe(false)
|
|
expect(chat.create).not.toHaveBeenCalled()
|
|
expect(trajectory.create).not.toHaveBeenCalled()
|
|
expect(buildChat).not.toHaveBeenCalled()
|
|
expect(buildTrajectory).not.toHaveBeenCalled()
|
|
|
|
expect(assembler.activateTarget('chat')).toBe(true)
|
|
expect(chat.replace).toHaveBeenCalledOnce()
|
|
expect(trajectory.replace).not.toHaveBeenCalled()
|
|
expect(buildChat).toHaveBeenCalledOnce()
|
|
expect(buildTrajectory).not.toHaveBeenCalled()
|
|
|
|
assembler.append(input(at(SessionSeq(2), 'active/update', {})))
|
|
expect(assembler.flush()).toBe(true)
|
|
expect(chat.apply).toHaveBeenCalledOnce()
|
|
expect(trajectory.apply).not.toHaveBeenCalled()
|
|
expect(buildTrajectory).not.toHaveBeenCalled()
|
|
|
|
expect(assembler.activateTarget('trajectory')).toBe(true)
|
|
expect(trajectory.replace).toHaveBeenCalledOnce()
|
|
expect((assembler.snapshot('trajectory') as readonly ConversationViewNode[])
|
|
.map(node => node.data)).toEqual([{ updates: 1 }])
|
|
|
|
assembler.append(input(at(SessionSeq(3), 'active/update', {})))
|
|
expect(assembler.flush()).toBe(true)
|
|
expect(chat.apply).toHaveBeenCalledTimes(2)
|
|
expect(trajectory.apply).toHaveBeenCalledOnce()
|
|
expect(assembler.activateTarget('chat')).toBe(false)
|
|
expect(assembler.activateTarget('trajectory')).toBe(false)
|
|
expect(chat.replace).toHaveBeenCalledOnce()
|
|
expect(trajectory.replace).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
|
|
const starts = vi.fn((
|
|
_context: ConversationNodeContext<{ callSeq: number; results: number }>,
|
|
match: ConversationMatch,
|
|
) => ({ callSeq: match.event.seq, results: 0 }))
|
|
const updates = vi.fn((context: { state: { callSeq: number; results: number } }) => ({
|
|
...context.state,
|
|
results: context.state.results + 1,
|
|
}))
|
|
const definition: ConversationNodeDefinition<{ callSeq: number; results: number }> = {
|
|
kind: 'tool',
|
|
match: (event) => {
|
|
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
|
|
if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
|
|
return null
|
|
},
|
|
start: starts,
|
|
update: updates,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })),
|
|
input(at(SessionSeq(2), 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })),
|
|
], false)
|
|
assembler.flush()
|
|
starts.mockClear()
|
|
|
|
assembler.append(input(at(SessionSeq(3), 'tool/result', {
|
|
turn: 1,
|
|
step: 1,
|
|
message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
|
|
})))
|
|
assembler.flush()
|
|
|
|
expect(starts).not.toHaveBeenCalled()
|
|
expect(updates).toHaveBeenCalledOnce()
|
|
const snapshot = testSnapshot(assembler)
|
|
expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([
|
|
{ callSeq: 1, results: 1 },
|
|
{ callSeq: 2, results: 0 },
|
|
])
|
|
const keys = snapshot?.order
|
|
for (const [index, callId] of ['b', 'a', 'b'].entries()) {
|
|
assembler.append(input(at(SessionSeq(4 + index), 'tool/result', {
|
|
turn: 1, step: 1,
|
|
message: { source: { type: 'tool-result', callId }, content: [], isError: false },
|
|
})))
|
|
}
|
|
assembler.flush()
|
|
expect(starts).not.toHaveBeenCalled()
|
|
expect(updates).toHaveBeenCalledTimes(4)
|
|
expect(testSnapshot(assembler)?.order).toEqual(keys)
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)).toEqual([
|
|
{ callSeq: 1, results: 2 },
|
|
{ callSeq: 2, results: 2 },
|
|
])
|
|
})
|
|
|
|
it('shares input Matches by role without sharing Definition State or Session locations', () => {
|
|
type State = { readonly seqs: readonly number[] }
|
|
const opening = [
|
|
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
|
|
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
|
|
input(at(SessionSeq(3), 'shared/seed', { turn: 1, step: 1 })),
|
|
]
|
|
const createSession = () => {
|
|
const probes = (['start', 'start', 'update', 'update'] as const).map((role, index) => {
|
|
const matches: ConversationMatch[] = []
|
|
const definition: ConversationNodeDefinition<State> = {
|
|
kind: `shared-${index}`,
|
|
target: 'test',
|
|
match: (event) => {
|
|
const type: string = event.type
|
|
if (type === 'shared/seed' && role === 'update') return { id: 'one', role: 'start' }
|
|
if (type === 'shared/input') return { id: 'one', role }
|
|
if (type === 'shared/first-only' && index === 0) return { id: 'one', role: 'update' }
|
|
return null
|
|
},
|
|
start: (_context, match) => {
|
|
matches.push(match)
|
|
return { seqs: [match.event.seq] }
|
|
},
|
|
update: (context, match) => {
|
|
matches.push(match)
|
|
return { seqs: [...context.state.seqs, match.event.seq] }
|
|
},
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
return { definition, matches }
|
|
})
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions(probes.map(probe => probe.definition)),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow(opening, false)
|
|
assembler.flush()
|
|
const states = () => {
|
|
const nodes = [...testSnapshot(assembler)?.nodes.values() ?? []]
|
|
return probes.map(probe => nodes.find(value => value.kind === probe.definition.kind)?.data)
|
|
}
|
|
return { assembler, probes, states }
|
|
}
|
|
const first = createSession()
|
|
const second = createSession()
|
|
const shared = input(at(SessionSeq(4), 'shared/input', { turn: 1, step: 1 }))
|
|
const expectedStates = [{ seqs: [4] }, { seqs: [4] }, { seqs: [3, 4] }, { seqs: [3, 4] }]
|
|
for (const session of [first, second]) {
|
|
session.assembler.append(shared)
|
|
session.assembler.flush()
|
|
const start = session.probes[0]!.matches.at(-1)!
|
|
const update = session.probes[2]!.matches.at(-1)!
|
|
expect(start).toBe(session.probes[1]!.matches.at(-1))
|
|
expect(update).toBe(session.probes[3]!.matches.at(-1))
|
|
expect(start).not.toBe(update)
|
|
expect(start.role).toBe('start')
|
|
expect(update.role).toBe('update')
|
|
expect(start.event).toBe(shared.event)
|
|
expect(update.event).toBe(shared.event)
|
|
expect(start.location.kind).toBe('step')
|
|
expect(start.location).toBe(update.location)
|
|
expect(session.states()).toEqual(expectedStates)
|
|
}
|
|
expect(new Set([...first.states(), ...second.states()]).size).toBe(8)
|
|
for (const index of [0, 2]) {
|
|
const left = first.probes[index]!.matches.at(-1)!
|
|
const right = second.probes[index]!.matches.at(-1)!
|
|
expect(left).not.toBe(right)
|
|
expect(left.location).not.toBe(right.location)
|
|
}
|
|
|
|
const secondSnapshot = testSnapshot(second.assembler)
|
|
first.assembler.append(input(at(SessionSeq(5), 'shared/first-only', { turn: 1, step: 1 })))
|
|
first.assembler.flush()
|
|
expect(first.probes[0]!.matches.at(-1)).not.toBe(first.probes[2]!.matches.at(-1))
|
|
expect(first.probes[2]!.matches.at(-1)!.event).toBe(shared.event)
|
|
expect(first.states()).toEqual([{ seqs: [4, 5] }, ...expectedStates.slice(1)])
|
|
expect(testSnapshot(second.assembler)).toBe(secondSnapshot)
|
|
expect(second.states()).toEqual(expectedStates)
|
|
})
|
|
|
|
it('keeps one Match collection while a long Context appends without replay', () => {
|
|
const starts = vi.fn(() => 0)
|
|
const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
|
|
context.state + 1
|
|
))
|
|
const matchCollections = new Set<readonly ConversationMatch[]>()
|
|
const definition: ConversationNodeDefinition<number> = {
|
|
kind: 'append-linear',
|
|
match: (event) => {
|
|
const type: string = event.type
|
|
if (type === 'linear/start') return { id: 'one', role: 'start' }
|
|
if (type === 'linear/update') return { id: 'one', role: 'update' }
|
|
return null
|
|
},
|
|
start: (context) => {
|
|
matchCollections.add(context.matches)
|
|
return starts()
|
|
},
|
|
update: (context) => {
|
|
matchCollections.add(context.matches)
|
|
return updates(context)
|
|
},
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'linear/start', {}))], false)
|
|
starts.mockClear()
|
|
|
|
for (let seq = 2; seq <= 1_001; seq++) {
|
|
assembler.append(input(at(SessionSeq(seq), 'linear/update', {})))
|
|
}
|
|
assembler.flush()
|
|
|
|
expect(starts).not.toHaveBeenCalled()
|
|
expect(updates).toHaveBeenCalledTimes(1_000)
|
|
expect(matchCollections.size).toBe(1)
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000)
|
|
})
|
|
|
|
it('keeps one transient Match through replace, Location replay, and Registry rebuild', () => {
|
|
interface State {
|
|
readonly updates: readonly string[]
|
|
readonly transientStatus: string | undefined
|
|
}
|
|
|
|
const matches = vi.fn((event: SessionEventLike) => {
|
|
if (event.type === 'step/start') return { id: '2:3', role: 'start' as const }
|
|
if ((event.type as string) === 'probe/update'
|
|
|| event.type === 'assistant/live-chunk') {
|
|
return { id: '2:3', role: 'update' as const }
|
|
}
|
|
return null
|
|
})
|
|
const passiveMatches = vi.fn(() => null)
|
|
const updates = vi.fn((
|
|
context: ConversationNodeContext<State> & { readonly state: State },
|
|
match: ConversationMatch,
|
|
): State => {
|
|
if (match.event.type === 'assistant/live-chunk') {
|
|
return {
|
|
...context.state,
|
|
updates: [
|
|
...context.state.updates,
|
|
`transient:${String(match.event.seq)}:${match.event.data.chunk.type}`,
|
|
],
|
|
transientStatus: match.location.kind === 'step'
|
|
? match.location.step.status
|
|
: match.location.kind,
|
|
}
|
|
}
|
|
return {
|
|
...context.state,
|
|
updates: [...context.state.updates, `event:${String(match.event.seq)}`],
|
|
}
|
|
})
|
|
const definition: ConversationNodeDefinition<State> = {
|
|
kind: 'transient-probe',
|
|
match: matches,
|
|
start: () => ({ updates: [], transientStatus: undefined }),
|
|
update: updates,
|
|
target: 'test',
|
|
buildViewNode: context => context.state === undefined
|
|
? null
|
|
: node(context, {
|
|
...context.state,
|
|
matches: context.matches.map(match => ({
|
|
type: match.event.type,
|
|
seq: match.event.seq,
|
|
})),
|
|
}),
|
|
}
|
|
const passive: ConversationNodeDefinition<null> = {
|
|
kind: 'transient-passive',
|
|
match: passiveMatches,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
}
|
|
const delta = transientChunk(12.5, 2, 3, { type: 'text-delta', index: 0, text: 'abc' })
|
|
const inputs: SessionEventLikeEntry[] = [
|
|
input(at(SessionSeq(10), 'step/start', { turn: 2, step: 3 })),
|
|
input(at(SessionSeq(11), 'probe/update', { turn: 2, step: 3 })),
|
|
delta,
|
|
input(at(SessionSeq(15), 'probe/update', { turn: 2, step: 3 })),
|
|
]
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition, passive]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
|
|
assembler.replaceWindow(inputs, false)
|
|
assembler.flush()
|
|
|
|
expect(matches).toHaveBeenCalledTimes(4)
|
|
expect(passiveMatches).toHaveBeenCalledTimes(4)
|
|
expect(updates).toHaveBeenCalledTimes(3)
|
|
expect(updates.mock.calls.filter(([, match]) => (
|
|
match.event.type === 'assistant/live-chunk'
|
|
))).toHaveLength(1)
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual({
|
|
updates: ['event:11', 'transient:12.5:text-delta', 'event:15'],
|
|
transientStatus: 'open',
|
|
matches: [
|
|
{ type: 'step/start', seq: 10 },
|
|
{ type: 'probe/update', seq: 11 },
|
|
{ type: 'assistant/live-chunk', seq: 12.5 },
|
|
{ type: 'probe/update', seq: 15 },
|
|
],
|
|
})
|
|
|
|
assembler.append(input(at(SessionSeq(16), 'step/end', { turn: 2, step: 3 })))
|
|
assembler.flush()
|
|
|
|
expect(updates.mock.calls.filter(([, match]) => (
|
|
match.event.type === 'assistant/live-chunk'
|
|
))).toHaveLength(2)
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toMatchObject({
|
|
updates: ['event:11', 'transient:12.5:text-delta', 'event:15'],
|
|
transientStatus: 'closed',
|
|
})
|
|
|
|
matches.mockClear()
|
|
passiveMatches.mockClear()
|
|
updates.mockClear()
|
|
assembler.rebuildRegistry()
|
|
assembler.flush()
|
|
|
|
expect(matches).toHaveBeenCalledTimes(5)
|
|
expect(passiveMatches).toHaveBeenCalledTimes(5)
|
|
expect(updates).toHaveBeenCalledTimes(3)
|
|
expect(updates.mock.calls.filter(([, match]) => (
|
|
match.event.type === 'assistant/live-chunk'
|
|
))).toHaveLength(1)
|
|
})
|
|
|
|
it('settles one Assistant attempt without replacing unrelated Contexts', () => {
|
|
interface State { readonly events: readonly string[] }
|
|
const definition: ConversationNodeDefinition<State> = {
|
|
kind: 'assistant-settlement',
|
|
target: 'test',
|
|
match: (event) => {
|
|
if (event.type === 'step/start') {
|
|
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
|
|
}
|
|
if (event.type === 'assistant/live-chunk'
|
|
|| event.type === 'assistant/message'
|
|
|| event.type === 'assistant/attempt'
|
|
|| event.type === 'llm/retry') {
|
|
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
|
|
}
|
|
return null
|
|
},
|
|
start: () => ({ events: [] }),
|
|
update: (context, match) => ({
|
|
events: [...context.state.events, match.event.type],
|
|
}),
|
|
buildViewNode: context => context.state === undefined
|
|
? null
|
|
: node(context, context.state.events),
|
|
}
|
|
const apply = vi.fn()
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView(apply)]),
|
|
)
|
|
const firstStart = input(at(SessionSeq(10), 'step/start', { turn: 2, step: 3 }))
|
|
const secondStart = input(at(SessionSeq(11), 'step/start', { turn: 2, step: 4 }))
|
|
const delta = transientChunk(11.5, 2, 3, { type: 'text-delta', index: 0, text: 'abc' })
|
|
const later = input(at(SessionSeq(13), 'llm/retry', { turn: 2, step: 3 }))
|
|
assembler.replaceWindow([firstStart, secondStart, delta, later], false)
|
|
assembler.flush()
|
|
const before = [...testSnapshot(assembler)?.nodes.values() ?? []]
|
|
const unaffected = before.find(candidate => candidate.id === '2:4')
|
|
expect(unaffected).toBeDefined()
|
|
apply.mockClear()
|
|
const settlementEvent = at(SessionSeq(12), 'assistant/message', {
|
|
turn: 2,
|
|
step: 3,
|
|
message: { role: 'assistant', content: [], source: { kind: 'model', provider: 'p', model: 'm' } },
|
|
stream: [],
|
|
})
|
|
if (settlementEvent.type !== 'assistant/message') throw new Error('expected Assistant settlement')
|
|
const settlement = { type: 'event' as const, event: settlementEvent }
|
|
|
|
expect(assembler.settleAssistant(LlmAttemptId('test-attempt'), settlement)).toBe('immediate')
|
|
assembler.flush()
|
|
|
|
const after = [...testSnapshot(assembler)?.nodes.values() ?? []]
|
|
expect(after.find(candidate => candidate.id === '2:3')?.data)
|
|
.toEqual(['assistant/message', 'llm/retry'])
|
|
expect(after.find(candidate => candidate.id === '2:4')).toBe(unaffected)
|
|
expect(apply).toHaveBeenCalledOnce()
|
|
expect(apply.mock.calls[0]?.[0]).toHaveLength(1)
|
|
|
|
assembler.append(transientChunk(13.5, 2, 3, { type: 'reasoning-delta', index: 0, text: 'x' }))
|
|
assembler.flush()
|
|
expect(assembler.settleAssistant(LlmAttemptId('test-attempt'))).toBe('immediate')
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []]
|
|
.find(candidate => candidate.id === '2:3')?.data)
|
|
.toEqual(['assistant/message', 'llm/retry'])
|
|
})
|
|
|
|
it.each(['update', 'publication'] as const)('retires every accepted transient owner after a %s failure', (failure) => {
|
|
const definitions: ConversationNodeDefinition<number>[] = Array.from({ length: 6 }, (_, index) => ({
|
|
kind: index === 5 ? 'unrelated' : `owner-${index}`,
|
|
target: 'test',
|
|
match: event => event.type === 'step/start'
|
|
? { id: 'one', role: 'start' }
|
|
: index < 5 && event.type === 'assistant/live-chunk' ? { id: 'one', role: 'update' } : null,
|
|
start: () => 0,
|
|
update: (context) => {
|
|
if (index === 4 && failure === 'update') throw new Error('owner failure')
|
|
return context.state + 1
|
|
},
|
|
publication: (match) => {
|
|
if (index === 4 && failure === 'publication' && match.event.type === 'assistant/live-chunk') {
|
|
throw new Error('owner failure')
|
|
}
|
|
return 'immediate'
|
|
},
|
|
buildViewNode: context => node(context, {
|
|
count: context.state, seqs: context.matches.map(match => match.event.seq),
|
|
}),
|
|
}))
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions(definitions), new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'step/start', { turn: 1, step: 1 }))], false)
|
|
assembler.flush()
|
|
const before = testSnapshot(assembler)!
|
|
expect(before.order).toHaveLength(6)
|
|
const unrelated = before.nodes.get(before.order[5]!)
|
|
const delta = transientChunk(1.5, 1, 1, {
|
|
type: 'tool-call-delta', index: 0, id: ToolCallId('shared'), name: 'write', argumentsDelta: '{}',
|
|
})
|
|
expect(() => assembler.append(delta)).toThrow('owner failure')
|
|
assembler.flush()
|
|
const partial = testSnapshot(assembler)!
|
|
for (const [index, key] of before.order.entries()) {
|
|
const changed = index < 4 || (index === 4 && failure === 'publication')
|
|
expect(partial.nodes.get(key)?.data).toEqual({ count: changed ? 1 : 0, seqs: changed ? [1, 1.5] : [1] })
|
|
}
|
|
expect(assembler.append(delta)).toBe('none')
|
|
assembler.flush()
|
|
expect(testSnapshot(assembler)).toBe(partial)
|
|
expect(assembler.settleAssistant(LlmAttemptId('test-attempt'))).toBe('immediate')
|
|
assembler.flush()
|
|
const settled = testSnapshot(assembler)!
|
|
for (const key of before.order.slice(0, 5)) {
|
|
expect(settled.nodes.get(key)?.data).toEqual({ count: 0, seqs: [1] })
|
|
expect(settled.nodes.get(key)).not.toBe(partial.nodes.get(key))
|
|
}
|
|
expect(settled.nodes.get(before.order[5]!)).toBe(unrelated)
|
|
assembler.rebuildRegistry()
|
|
assembler.flush()
|
|
const rebuilt = testSnapshot(assembler)!
|
|
expect(rebuilt.order).toEqual(before.order)
|
|
expect([...rebuilt.nodes.values()].map(value => value.data)).toEqual([...before.nodes.values()].map(value => value.data))
|
|
})
|
|
|
|
it('replays one pending transient Match after prepend supplies its durable start', () => {
|
|
const starts = vi.fn(() => ({ batches: 0, status: 'unresolved' }))
|
|
const updates = vi.fn((
|
|
context: ConversationNodeContext<{ batches: number; status: string }> & {
|
|
readonly state: { batches: number; status: string }
|
|
},
|
|
match: ConversationMatch,
|
|
) => ({
|
|
batches: context.state.batches + 1,
|
|
status: match.location.kind === 'step' ? match.location.step.status : match.location.kind,
|
|
}))
|
|
const definition: ConversationNodeDefinition<{ batches: number; status: string }> = {
|
|
kind: 'transient-pending',
|
|
match: (event) => {
|
|
if (event.type === 'step/start') {
|
|
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
|
|
}
|
|
if (event.type === 'assistant/live-chunk') {
|
|
return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
|
|
}
|
|
return null
|
|
},
|
|
start: starts,
|
|
update: updates,
|
|
target: 'test',
|
|
buildViewNode: context => context.state === undefined
|
|
? null
|
|
: node(context, {
|
|
...context.state,
|
|
matches: context.matches.map(match => [match.event.type, match.event.seq]),
|
|
}),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
const delta = transientChunk(21, 4, 5, { type: 'reasoning-delta', index: 0, text: 'x' })
|
|
|
|
assembler.replaceWindow([delta], true)
|
|
assembler.flush()
|
|
|
|
expect(starts).not.toHaveBeenCalled()
|
|
expect(updates).not.toHaveBeenCalled()
|
|
expect(testSnapshot(assembler)?.order).toEqual([])
|
|
|
|
assembler.prepend([
|
|
input(at(SessionSeq(20), 'step/start', { turn: 4, step: 5 })),
|
|
], false)
|
|
assembler.flush()
|
|
|
|
expect(starts).toHaveBeenCalledOnce()
|
|
expect(updates).toHaveBeenCalledOnce()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual({
|
|
batches: 1,
|
|
status: 'open',
|
|
matches: [['step/start', 20], ['assistant/live-chunk', 21]],
|
|
})
|
|
})
|
|
|
|
it('initializes from a transient start and clears it when the attempt is withdrawn', () => {
|
|
const start = vi.fn((_context: ConversationNodeContext<number>, match: ConversationMatch) => match.event.seq)
|
|
const definition: ConversationNodeDefinition<number> = {
|
|
kind: 'transient-start',
|
|
match: event => event.type === 'assistant/live-chunk'
|
|
? { id: 'one', role: 'start' }
|
|
: null,
|
|
start,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state ?? null),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
const delta = transientChunk(1, 1, 1, { type: 'text-delta', index: 0, text: 'abc' })
|
|
|
|
assembler.replaceWindow([delta], false)
|
|
assembler.flush()
|
|
expect(start).toHaveBeenCalledOnce()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
|
|
assembler.settleAssistant(LlmAttemptId('test-attempt'))
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBeNull()
|
|
})
|
|
|
|
it('merges an older page and replays its affected Context once', () => {
|
|
const starts = vi.fn(() => 0)
|
|
const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
|
|
context.state + 1
|
|
))
|
|
const definition: ConversationNodeDefinition<number> = {
|
|
kind: 'prepend-linear',
|
|
match: (event) => {
|
|
const type: string = event.type
|
|
if (type === 'linear/start') return { id: 'one', role: 'start' }
|
|
if (type === 'linear/update') return { id: 'one', role: 'update' }
|
|
return null
|
|
},
|
|
start: starts,
|
|
update: updates,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
const current = Array.from({ length: 100 }, (_, index) => (
|
|
input(at(SessionSeq(index + 102), 'linear/update', {}))
|
|
))
|
|
assembler.replaceWindow(current, true)
|
|
assembler.flush()
|
|
expect(starts).not.toHaveBeenCalled()
|
|
expect(updates).not.toHaveBeenCalled()
|
|
|
|
const older = [
|
|
input(at(SessionSeq(1), 'linear/start', {})),
|
|
...Array.from({ length: 100 }, (_, index) => (
|
|
input(at(SessionSeq(index + 2), 'linear/update', {}))
|
|
)),
|
|
]
|
|
assembler.prepend(older, false)
|
|
assembler.flush()
|
|
|
|
expect(starts).toHaveBeenCalledOnce()
|
|
expect(updates).toHaveBeenCalledTimes(200)
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200)
|
|
})
|
|
|
|
it('collects an update before its start and replays it once prepend supplies the start', () => {
|
|
const updates = vi.fn((context: { state: { settled: boolean } }) => ({ ...context.state, settled: true }))
|
|
const definition: ConversationNodeDefinition<{ settled: boolean }> = {
|
|
kind: 'tool',
|
|
match: (event) => {
|
|
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
|
|
if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
|
|
return null
|
|
},
|
|
start: () => ({ settled: false }),
|
|
update: updates,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(10), 'tool/result', {
|
|
turn: 1,
|
|
step: 1,
|
|
message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
|
|
}))], true)
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
|
.toEqual({ pendingStart: true })
|
|
|
|
assembler.prepend([input(at(SessionSeq(5), 'tool/call', {
|
|
turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}',
|
|
}))], false)
|
|
assembler.flush()
|
|
|
|
expect(updates).toHaveBeenCalledOnce()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
|
.toEqual({ settled: true })
|
|
})
|
|
|
|
it('rejects a Definition whose declared start follows an update in log order', () => {
|
|
const definition: ConversationNodeDefinition<null> = {
|
|
kind: 'invalid-lifecycle',
|
|
match: event => event.type === 'turn/end'
|
|
? { id: 'one', role: 'start' }
|
|
: event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: () => null,
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
|
|
expect(() => assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
|
|
input(at(SessionSeq(2), 'turn/end', { turn: 1, reason: { kind: 'completed' } })),
|
|
], false)).toThrow('received an update before its start Match')
|
|
})
|
|
|
|
it('replays a window-gap reader when prepend supplies a nearer predecessor', () => {
|
|
const source: ConversationNodeDefinition<number> = {
|
|
kind: 'source',
|
|
match: event => event.type === 'user/message'
|
|
? { id: String(event.data.id), role: 'start' }
|
|
: null,
|
|
start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: () => null,
|
|
}
|
|
const consumerStart = vi.fn((
|
|
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
|
|
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
|
|
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
|
|
) => reader.previous<number>('source')?.state ?? -1)
|
|
const consumer: ConversationNodeDefinition<number> = {
|
|
kind: 'consumer',
|
|
match: event => event.type === 'assistant/message'
|
|
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
|
: null,
|
|
start: consumerStart,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([source, consumer]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(10), 'assistant/message', {
|
|
turn: 2, step: 1, message: { role: 'assistant', content: [] },
|
|
stream: [],
|
|
}))], true)
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
|
|
|
|
assembler.prepend([input(at(SessionSeq(5), 'user/message', {
|
|
id: 'm1', value: 7, content: [], source: { kind: 'user' },
|
|
}))], false)
|
|
assembler.flush()
|
|
|
|
expect(consumerStart).toHaveBeenCalledTimes(2)
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7)
|
|
})
|
|
|
|
it('keeps the predecessor index ordered across prepend and append', () => {
|
|
const source: ConversationNodeDefinition<number> = {
|
|
kind: 'source',
|
|
match: event => event.type === 'user/message'
|
|
? { id: String(event.data.id), role: 'start' }
|
|
: null,
|
|
start: (_context, match) => match.event.seq,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: () => null,
|
|
}
|
|
const consumer: ConversationNodeDefinition<number> = {
|
|
kind: 'consumer',
|
|
match: event => event.type === 'assistant/message'
|
|
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
|
: null,
|
|
start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([source, consumer]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(40), 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })),
|
|
input(at(SessionSeq(50), 'assistant/message', {
|
|
turn: 1, step: 1, message: { role: 'assistant', content: [] },
|
|
stream: [],
|
|
})),
|
|
], true)
|
|
assembler.flush()
|
|
|
|
assembler.prepend([
|
|
input(at(SessionSeq(10), 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })),
|
|
input(at(SessionSeq(30), 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })),
|
|
], false)
|
|
assembler.flush()
|
|
assembler.append(input(at(SessionSeq(60), 'user/message', {
|
|
id: 'm60', content: [], source: { kind: 'user' },
|
|
})))
|
|
assembler.append(input(at(SessionSeq(70), 'assistant/message', {
|
|
turn: 2, step: 1, message: { role: 'assistant', content: [] },
|
|
stream: [],
|
|
})))
|
|
assembler.flush()
|
|
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
|
|
.toEqual([40, 60])
|
|
})
|
|
|
|
it('replays a window-gap reader when an empty prepend closes the unknown prefix', () => {
|
|
const consumerStart = vi.fn((
|
|
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
|
|
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
|
|
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
|
|
) => reader.previous<number>('source')?.state ?? -1)
|
|
const consumer: ConversationNodeDefinition<number> = {
|
|
kind: 'consumer',
|
|
match: event => event.type === 'assistant/message'
|
|
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
|
: null,
|
|
start: consumerStart,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([consumer]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(10), 'assistant/message', {
|
|
turn: 2, step: 1, message: { role: 'assistant', content: [] },
|
|
stream: [],
|
|
}))], true)
|
|
assembler.flush()
|
|
|
|
expect(assembler.prepend([], false)).toBe('immediate')
|
|
assembler.flush()
|
|
|
|
expect(consumerStart).toHaveBeenCalledTimes(2)
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
|
|
})
|
|
|
|
it('reorders predecessor reads when durable calls replace transient starts after dispatch', () => {
|
|
const source: ConversationNodeDefinition<number> = {
|
|
kind: 'reindexed-source',
|
|
match: (event) => {
|
|
if (event.type === 'assistant/live-chunk' && event.data.chunk.type === 'tool-call-delta') {
|
|
return { id: String(event.data.chunk.id), role: 'start' }
|
|
}
|
|
return event.type === 'tool/call' ? { id: String(event.data.callId), role: 'start' } : null
|
|
},
|
|
start: (_context, match) => match.event.seq,
|
|
update: context => context.state,
|
|
target: 'test', buildViewNode: () => null,
|
|
}
|
|
const consumer: ConversationNodeDefinition<number> = {
|
|
kind: 'consumer',
|
|
match: event => event.type === 'step/end' ? { id: String(event.seq), role: 'start' } : null,
|
|
start: (_context, _match, reader) => reader.previous<number>('reindexed-source')?.state ?? -1,
|
|
update: context => context.state,
|
|
target: 'test', buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([source, consumer]), new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([], false)
|
|
for (const [index, id] of ['a', 'b'].entries()) {
|
|
assembler.append(transientChunk(1.1 + index / 10, 1, 1, {
|
|
type: 'tool-call-delta', index, id: ToolCallId(id), name: 'write', argumentsDelta: '',
|
|
}))
|
|
}
|
|
assembler.append(input(at(SessionSeq(3), 'tool/call', { turn: 1, step: 1, callId: ToolCallId('b'), name: 'write', arguments: '{}' })))
|
|
assembler.append(input(at(SessionSeq(4), 'tool/call', { turn: 1, step: 1, callId: ToolCallId('a'), name: 'write', arguments: '{}' })))
|
|
assembler.append(input(at(SessionSeq(5), 'step/end', { turn: 1, step: 1 })))
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)!.nodes.values()][0]?.data).toBeCloseTo(1.2)
|
|
assembler.settleAssistant(LlmAttemptId('test-attempt'))
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)!.nodes.values()][0]?.data).toBe(4)
|
|
})
|
|
|
|
it('replays direct dependents when an append revises their predecessor Context', () => {
|
|
const source: ConversationNodeDefinition<number> = {
|
|
kind: 'source',
|
|
match: (event) => {
|
|
if (event.type === 'user/message') return { id: 'one', role: 'start' }
|
|
if ((event.type as string) === 'source/update') return { id: 'one', role: 'update' }
|
|
return null
|
|
},
|
|
start: () => 1,
|
|
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
|
target: 'test',
|
|
buildViewNode: () => null,
|
|
}
|
|
const consumerStart = vi.fn((
|
|
_context: Parameters<ConversationNodeDefinition<number>['start']>[0],
|
|
_match: Parameters<ConversationNodeDefinition<number>['start']>[1],
|
|
reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
|
|
) => reader.previous<number>('source')?.state ?? -1)
|
|
const consumer: ConversationNodeDefinition<number> = {
|
|
kind: 'consumer',
|
|
match: event => event.type === 'assistant/message'
|
|
? { id: 'one', role: 'start' }
|
|
: null,
|
|
start: consumerStart,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([source, consumer]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
|
|
input(at(SessionSeq(2), 'assistant/message', {
|
|
turn: 1, step: 1, message: { role: 'assistant', content: [] }, stream: [],
|
|
})),
|
|
], false)
|
|
assembler.flush()
|
|
|
|
expect(assembler.append(input(at(SessionSeq(3), 'source/update', { value: 2 })))).toBe('immediate')
|
|
assembler.flush()
|
|
|
|
expect(consumerStart).toHaveBeenCalledTimes(2)
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2)
|
|
})
|
|
|
|
it('replays a transitive dependency closure in start order', () => {
|
|
const sourceA: ConversationNodeDefinition<number> = {
|
|
kind: 'diamond-a',
|
|
match: (event) => {
|
|
if (event.type === 'user/message') return { id: 'one', role: 'start' }
|
|
if ((event.type as string) === 'diamond/a') return { id: 'one', role: 'update' }
|
|
return null
|
|
},
|
|
start: () => 1,
|
|
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
|
target: 'test',
|
|
buildViewNode: () => null,
|
|
}
|
|
const sourceX: ConversationNodeDefinition<number> = {
|
|
kind: 'diamond-x',
|
|
match: (event) => {
|
|
if (event.type === 'turn/start') return { id: 'one', role: 'start' }
|
|
if ((event.type as string) === 'diamond/x') return { id: 'one', role: 'update' }
|
|
return null
|
|
},
|
|
start: () => 10,
|
|
update: (_context, match) => (match.event.data as unknown as { value: number }).value,
|
|
target: 'test',
|
|
buildViewNode: () => null,
|
|
}
|
|
const middle: ConversationNodeDefinition<number> = {
|
|
kind: 'diamond-b',
|
|
match: event => event.type === 'assistant/message'
|
|
? { id: 'one', role: 'start' }
|
|
: null,
|
|
start: (_context, _match, reader) => (
|
|
(reader.previous<number>('diamond-a')?.state ?? 0)
|
|
+ (reader.previous<number>('diamond-x')?.state ?? 0)
|
|
),
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const consumer: ConversationNodeDefinition<number> = {
|
|
kind: 'diamond-c',
|
|
match: event => event.type === 'tool/call'
|
|
? { id: 'one', role: 'start' }
|
|
: null,
|
|
start: (_context, _match, reader) => (
|
|
(reader.previous<number>('diamond-a')?.state ?? 0) * 100
|
|
+ (reader.previous<number>('diamond-b')?.state ?? 0)
|
|
),
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([sourceA, sourceX, middle, consumer]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
|
|
input(at(SessionSeq(2), 'turn/start', { turn: 1 })),
|
|
input(at(SessionSeq(3), 'assistant/message', {
|
|
turn: 1, step: 1, message: { role: 'assistant', content: [] }, stream: [],
|
|
})),
|
|
input(at(SessionSeq(4), 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })),
|
|
], false)
|
|
|
|
assembler.append(input(at(SessionSeq(5), 'diamond/x', { value: 20 })))
|
|
assembler.append(input(at(SessionSeq(6), 'diamond/a', { value: 2 })))
|
|
assembler.flush()
|
|
|
|
const value = [...testSnapshot(assembler)?.nodes.values() ?? []]
|
|
.find(candidate => candidate.kind === 'diamond-c')
|
|
expect(value?.data).toBe(222)
|
|
})
|
|
|
|
it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => {
|
|
const apply = vi.fn()
|
|
const starts = vi.fn((
|
|
_context: Parameters<ConversationNodeDefinition<string>['start']>[0],
|
|
match: Parameters<ConversationNodeDefinition<string>['start']>[1],
|
|
) => match.location.kind === 'step' ? match.location.step.status : 'missing')
|
|
const definition: ConversationNodeDefinition<string> = {
|
|
kind: 'step',
|
|
match: event => event.type === 'step/start'
|
|
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
|
: null,
|
|
start: starts,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView(apply)]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
|
|
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
|
|
], false)
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open')
|
|
|
|
assembler.append(input(at(SessionSeq(3), 'step/end', { turn: 1, step: 1 })))
|
|
assembler.flush()
|
|
|
|
expect(starts).toHaveBeenCalledTimes(2)
|
|
expect(apply).toHaveBeenCalledOnce()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed')
|
|
})
|
|
|
|
it('lets one Context publish Step and Turn data in phase order', () => {
|
|
interface State {
|
|
readonly turn: number
|
|
readonly step: number
|
|
readonly value: number
|
|
}
|
|
|
|
const definition: ConversationNodeDefinition<State> = {
|
|
kind: 'scope-probe',
|
|
match: (event) => {
|
|
if (event.type === 'step/start') {
|
|
return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
|
}
|
|
if ((event.type as string) === 'scope-probe/update') {
|
|
return { id: '1:1', role: 'update' }
|
|
}
|
|
return null
|
|
},
|
|
start: (_context, match) => {
|
|
if (match.event.type !== 'step/start') throw new Error('scope probe requires step/start')
|
|
return { turn: match.event.data.turn, step: match.event.data.step, value: 1 }
|
|
},
|
|
update: (_context, match) => ({
|
|
turn: 1,
|
|
step: 1,
|
|
value: (match.event.data as unknown as { value: number }).value,
|
|
}),
|
|
buildLocationData: (context, scope) => {
|
|
const state = context.state
|
|
if (state === undefined) return null
|
|
if (scope === 'step') {
|
|
return {
|
|
kind: 'step',
|
|
turn: state.turn,
|
|
step: state.step,
|
|
key: 'scope-probe',
|
|
value: { value: state.value },
|
|
}
|
|
}
|
|
const location = context.start?.location
|
|
const stepValue = location?.kind === 'step'
|
|
? location.step.data.get('scope-probe')?.value
|
|
: undefined
|
|
return {
|
|
kind: 'turn',
|
|
turn: state.turn,
|
|
key: 'scope-probe',
|
|
value: { valueSeenFromStep: stepValue ?? -1 },
|
|
}
|
|
},
|
|
target: 'test',
|
|
buildViewNode: (context) => {
|
|
const location = context.start?.location
|
|
if (location?.kind !== 'step') return null
|
|
return node(context, {
|
|
step: location.step.data.get('scope-probe')?.value,
|
|
turn: location.turn.data.get('scope-probe')?.valueSeenFromStep,
|
|
})
|
|
},
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
|
|
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
|
|
], false)
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
|
.toEqual({ step: 1, turn: 1 })
|
|
|
|
assembler.append(input(at(SessionSeq(3), 'scope-probe/update', { turn: 1, step: 1, value: 2 })))
|
|
assembler.flush()
|
|
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
|
.toEqual({ step: 2, turn: 2 })
|
|
})
|
|
|
|
it('does not republish an equivalent Location value', () => {
|
|
const apply = vi.fn()
|
|
const initialValue = { value: 1 }
|
|
const changedValue = { value: 2 }
|
|
const definition: ConversationNodeDefinition<{ readonly value: ScopeProbeStepData }> = {
|
|
kind: 'scope-probe',
|
|
match: (event) => {
|
|
if (event.type === 'step/start') return { id: '1:1', role: 'start' }
|
|
if ((event.type as string) === 'scope-probe/update') return { id: '1:1', role: 'update' }
|
|
return null
|
|
},
|
|
start: () => ({ value: initialValue }),
|
|
update: (_context, match) => ({
|
|
value: (match.event.data as unknown as { changed: boolean }).changed
|
|
? changedValue
|
|
: initialValue,
|
|
}),
|
|
buildLocationData: (context, scope, previous) => {
|
|
if (scope !== 'step' || context.state === undefined) return null
|
|
if (previous?.kind === 'step'
|
|
&& previous.turn === 1
|
|
&& previous.step === 1
|
|
&& previous.key === 'scope-probe'
|
|
&& previous.value === context.state.value) return previous
|
|
return { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: context.state.value }
|
|
},
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView(apply)]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
|
|
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
|
|
], false)
|
|
assembler.flush()
|
|
|
|
assembler.append(input(at(SessionSeq(3), 'scope-probe/update', { turn: 1, step: 1, changed: false })))
|
|
expect(assembler.flush()).toBe(false)
|
|
expect(apply).not.toHaveBeenCalled()
|
|
|
|
assembler.append(input(at(SessionSeq(4), 'scope-probe/update', { turn: 1, step: 1, changed: true })))
|
|
expect(assembler.flush()).toBe(true)
|
|
expect(apply).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('updates existing turn Locations when their Step membership changes', () => {
|
|
const apply = vi.fn()
|
|
const definition: ConversationNodeDefinition<null> = {
|
|
kind: 'turn-probe',
|
|
match: event => event.type === 'turn/start'
|
|
? { id: String(event.data.turn), role: 'start' }
|
|
: null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.start?.location.kind === 'turn'
|
|
? context.start.location.turn.steps.length
|
|
: -1),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView(apply)]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'turn/start', { turn: 1 }))], false)
|
|
assembler.flush()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0)
|
|
|
|
assembler.append(input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })))
|
|
assembler.flush()
|
|
|
|
expect(apply).toHaveBeenCalledOnce()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
|
|
})
|
|
|
|
it('publishes a changed timeline even when no business Definition claims the boundary', () => {
|
|
const apply = vi.fn()
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([]),
|
|
new TestViewDefinitions([testView(apply)]),
|
|
)
|
|
assembler.replaceWindow([], false)
|
|
assembler.flush()
|
|
|
|
assembler.append(input(at(SessionSeq(1), 'turn/start', { turn: 1 })))
|
|
assembler.flush()
|
|
|
|
expect(apply).toHaveBeenCalledOnce()
|
|
expect(testSnapshot(assembler)?.order).toEqual([])
|
|
})
|
|
|
|
it('clears the prior Step at a new Turn and honors explicit session ownership', () => {
|
|
const definition: ConversationNodeDefinition<null> = {
|
|
kind: 'location-probe',
|
|
match: (event) => {
|
|
if ((event.type as string) === 'command/run') {
|
|
return {
|
|
id: (event.data as unknown as { commandId: string }).commandId,
|
|
role: 'start',
|
|
}
|
|
}
|
|
if ((event.type as string) === 'compaction/start') {
|
|
return {
|
|
id: (event.data as unknown as { compactionId: string }).compactionId,
|
|
role: 'start',
|
|
}
|
|
}
|
|
return null
|
|
},
|
|
start: () => null,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: (context) => {
|
|
const location = context.start?.location
|
|
const data = location?.kind === 'step'
|
|
? `step:${location.turn.turn}:${location.step.step}`
|
|
: location?.kind === 'turn' ? `turn:${location.turn.turn}` : location?.kind
|
|
return node(context, data)
|
|
},
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
|
|
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
|
|
input(at(SessionSeq(3), 'turn/start', { turn: 2 })),
|
|
input(at(SessionSeq(4), 'command/run', { commandId: 'command', name: 'x' })),
|
|
input(at(SessionSeq(5), 'compaction/start', { compactionId: 'compact', turn: null })),
|
|
], false)
|
|
assembler.flush()
|
|
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
|
|
.toEqual(['turn:2', 'session'])
|
|
})
|
|
|
|
it('assigns turn boundaries to the Turn even when a Step remains open', () => {
|
|
const definition: ConversationNodeDefinition<null> = {
|
|
kind: 'turn-boundary-probe',
|
|
match: event => event.type === 'turn/end'
|
|
? { id: String(event.data.turn), role: 'start' }
|
|
: null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.start?.location.kind),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
|
|
input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
|
|
], false)
|
|
assembler.flush()
|
|
|
|
assembler.append(input(at(SessionSeq(3), 'turn/end', { turn: 1, reason: { kind: 'aborted' } })))
|
|
assembler.flush()
|
|
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn')
|
|
})
|
|
|
|
it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => {
|
|
const definition: ConversationNodeDefinition<null> = {
|
|
kind: 'location-probe',
|
|
match: event => (event.type as string) === 'tool/ptc-dispatch-start'
|
|
? { id: String(event.seq), role: 'start' }
|
|
: null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: (context) => {
|
|
const location = context.start?.location
|
|
return node(context, location?.kind === 'step'
|
|
? `${location.turn.turn}:${location.step.step}`
|
|
: location?.kind)
|
|
},
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(10), 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
|
|
input(at(SessionSeq(11), 'tool/ptc-dispatch-start', { rootCallId: 'root', subCallId: 'a' })),
|
|
], true)
|
|
assembler.flush()
|
|
|
|
assembler.append(input(at(SessionSeq(12), 'tool/ptc-dispatch-start', { rootCallId: 'root', subCallId: 'b' })))
|
|
assembler.flush()
|
|
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
|
|
.toEqual(['2:3', '2:3'])
|
|
})
|
|
|
|
it('treats loaded end boundaries as closed when their starts precede the window', () => {
|
|
const definition: ConversationNodeDefinition<null> = {
|
|
kind: 'location-probe',
|
|
match: event => event.type === 'tool/call'
|
|
? { id: String(event.data.callId), role: 'start' }
|
|
: null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: (context) => {
|
|
const location = context.start?.location
|
|
return node(context, location?.kind === 'step'
|
|
? `${location.turn.status}:${location.step.status}`
|
|
: location?.kind)
|
|
},
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(10), 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
|
|
input(at(SessionSeq(11), 'step/end', { turn: 2, step: 3 })),
|
|
input(at(SessionSeq(12), 'turn/end', { turn: 2, reason: { kind: 'completed' } })),
|
|
], true)
|
|
assembler.flush()
|
|
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
|
|
.toBe('closed:closed')
|
|
})
|
|
|
|
it('restarts State creation from undefined when Location changes replay a Context', () => {
|
|
const seen = vi.fn((context: Parameters<ConversationNodeDefinition<number>['start']>[0]) => {
|
|
expect(context.state).toBeUndefined()
|
|
return 1
|
|
})
|
|
const definition: ConversationNodeDefinition<number> = {
|
|
kind: 'replay-probe',
|
|
match: event => event.type === 'step/start'
|
|
? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
|
: null,
|
|
start: seen,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'step/start', { turn: 1, step: 1 }))], false)
|
|
assembler.flush()
|
|
|
|
assembler.append(input(at(SessionSeq(2), 'step/end', { turn: 1, step: 1 })))
|
|
assembler.flush()
|
|
|
|
expect(seen).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('invokes the fallback when only a State-only Definition claims an event', () => {
|
|
const fallbackStart = vi.fn(() => 'fallback')
|
|
const claimed: ConversationNodeDefinition<null> = {
|
|
kind: 'claimed-state',
|
|
match: event => (event.type as string) === 'command/run'
|
|
? { id: 'claimed', role: 'start' }
|
|
: null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false)
|
|
assembler.flush()
|
|
|
|
expect(fallbackStart).toHaveBeenCalledOnce()
|
|
expect(testSnapshot(assembler)?.order).toHaveLength(1)
|
|
})
|
|
|
|
it('invokes the fallback when only another target claims an event', () => {
|
|
const fallbackStart = vi.fn(() => 'fallback')
|
|
const claimed: ConversationNodeDefinition<null> = {
|
|
kind: 'claimed-trajectory',
|
|
target: 'trajectory',
|
|
match: event => (event.type as string) === 'command/run'
|
|
? { id: 'claimed', role: 'start' }
|
|
: null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
buildViewNode: () => null,
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false)
|
|
assembler.flush()
|
|
|
|
expect(fallbackStart).toHaveBeenCalledOnce()
|
|
expect(testSnapshot(assembler)?.order).toHaveLength(1)
|
|
})
|
|
|
|
it('suppresses the fallback when the same target claims an event', () => {
|
|
const fallbackStart = vi.fn(() => 'fallback')
|
|
const claimed: ConversationNodeDefinition<null> = {
|
|
kind: 'claimed',
|
|
target: 'test',
|
|
match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
buildViewNode: () => null,
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false)
|
|
assembler.flush()
|
|
|
|
expect(fallbackStart).not.toHaveBeenCalled()
|
|
expect(testSnapshot(assembler)?.order).toEqual([])
|
|
})
|
|
|
|
describe('exclusive matches', () => {
|
|
const run = (seq: number, name: string) =>
|
|
input(at(SessionSeq(seq), 'command/run', { commandId: `c${String(seq)}`, name }))
|
|
const commandName = (event: SessionEventLike): string | undefined => (event.type as string) === 'command/run'
|
|
? (event.data as { readonly name: string }).name
|
|
: undefined
|
|
|
|
function genericDefinition(start = vi.fn(() => 'generic'), target = 'test'): ConversationNodeDefinition<string> {
|
|
return {
|
|
kind: `generic-${target}`,
|
|
target,
|
|
match: event => commandName(event) === undefined ? null : { id: String(event.seq), role: 'start' },
|
|
start,
|
|
update: context => context.state,
|
|
buildViewNode: context => target === 'test' ? node(context, context.state) : null,
|
|
}
|
|
}
|
|
|
|
// `null` builds a Definition without a target; an omitted argument keeps the test target.
|
|
function claimingDefinition(kind = 'specific', target: string | null = 'test'): ConversationNodeDefinition<string> {
|
|
return {
|
|
kind,
|
|
...target === null ? {} : { target },
|
|
match: event => commandName(event) === 'claimed'
|
|
? { id: String(event.seq), role: 'start', exclusive: true }
|
|
: null,
|
|
start: () => kind,
|
|
update: context => context.state,
|
|
...target === null ? {} : { buildViewNode: (context: ConversationNodeContext<string>) => node(context, context.state) },
|
|
}
|
|
}
|
|
|
|
function nodeData(assembler: ConversationNodeAssembler): unknown[] {
|
|
return [...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data)
|
|
}
|
|
|
|
it.each(['claim first', 'claim last'] as const)(
|
|
'withholds the claimed event from other Definitions of the target when registered %s',
|
|
(order) => {
|
|
const genericStart = vi.fn(() => 'generic')
|
|
const definitions = [claimingDefinition(), genericDefinition(genericStart)]
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions(order === 'claim first' ? definitions : [...definitions].reverse()),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
|
|
assembler.replaceWindow([run(1, 'claimed'), run(2, 'other')], false)
|
|
assembler.flush()
|
|
assembler.append(run(3, 'claimed'))
|
|
assembler.flush()
|
|
|
|
expect(genericStart).toHaveBeenCalledOnce()
|
|
expect(nodeData(assembler)).toEqual(['specific', 'generic', 'specific'])
|
|
},
|
|
)
|
|
|
|
it('keeps delivering the claimed event to other targets, target-less Definitions, and no fallback', () => {
|
|
const otherTargetStart = vi.fn(() => 'generic')
|
|
const stateOnlyStart = vi.fn(() => null)
|
|
const stateOnly: ConversationNodeDefinition<null> = {
|
|
kind: 'state-only',
|
|
match: event => commandName(event) === undefined ? null : { id: String(event.seq), role: 'start' },
|
|
start: stateOnlyStart,
|
|
update: context => context.state,
|
|
}
|
|
const fallbackStart = vi.fn(() => 'fallback')
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions(
|
|
[claimingDefinition(), genericDefinition(otherTargetStart, 'trajectory'), stateOnly],
|
|
fallbackDefinition(fallbackStart),
|
|
),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
|
|
assembler.replaceWindow([run(1, 'claimed')], false)
|
|
assembler.flush()
|
|
|
|
expect(otherTargetStart).toHaveBeenCalledOnce()
|
|
expect(stateOnlyStart).toHaveBeenCalledOnce()
|
|
expect(fallbackStart).not.toHaveBeenCalled()
|
|
expect(nodeData(assembler)).toEqual(['specific'])
|
|
})
|
|
|
|
it('rejects two claims on one target', () => {
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([claimingDefinition('first'), claimingDefinition('second')]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
|
|
expect(() => assembler.replaceWindow([run(1, 'claimed')], false)).toThrow(
|
|
'conversation Definitions "first" and "second" both claim command/run at seq 1 on target "test"',
|
|
)
|
|
})
|
|
|
|
it('rejects a claim from a Definition without a target', () => {
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([claimingDefinition('untargeted', null)]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
|
|
expect(() => assembler.replaceWindow([run(1, 'claimed')], false)).toThrow(
|
|
'conversation Definition "untargeted" claims command/run at seq 1 without a target',
|
|
)
|
|
})
|
|
})
|
|
|
|
it('rejects withdrawing a previously materialized Node during an incremental update', () => {
|
|
const definition: ConversationNodeDefinition<boolean> = {
|
|
kind: 'toggle',
|
|
match: (event) => {
|
|
if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
|
|
if ((event.type as string) === 'toggle/hide') return { id: 'one', role: 'update' }
|
|
return null
|
|
},
|
|
start: () => true,
|
|
update: () => false,
|
|
target: 'test',
|
|
buildViewNode: context => context.state === true ? node(context, true) : null,
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false)
|
|
assembler.flush()
|
|
expect(testSnapshot(assembler)?.order).toHaveLength(1)
|
|
|
|
assembler.append(input(at(SessionSeq(2), 'toggle/hide', {})))
|
|
expect(() => assembler.flush()).toThrow(/withdrew materialized target "test"/)
|
|
|
|
expect(testSnapshot(assembler)?.order).toHaveLength(1)
|
|
})
|
|
|
|
it('fails loud when a Definition returns undefined State', () => {
|
|
const startUndefined: ConversationNodeDefinition = {
|
|
kind: 'undefined-start',
|
|
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
|
|
start: () => undefined,
|
|
update: context => context.state,
|
|
target: 'test',
|
|
buildViewNode: () => null,
|
|
}
|
|
const startAssembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([startUndefined]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
expect(() => startAssembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' })),
|
|
], false)).toThrow(/Definition "undefined-start" returned undefined from start/)
|
|
|
|
const updateUndefined: ConversationNodeDefinition<boolean> = {
|
|
kind: 'undefined-update',
|
|
match: (event) => {
|
|
if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
|
|
if ((event.type as string) === 'command/done') return { id: 'one', role: 'update' }
|
|
return null
|
|
},
|
|
start: () => true,
|
|
update: () => undefined as never,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const updateAssembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([updateUndefined]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
updateAssembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' })),
|
|
], false)
|
|
expect(() => updateAssembler.append(
|
|
input(at(SessionSeq(2), 'command/done', { commandId: 'one', kind: 'success' })),
|
|
)).toThrow(/Definition "undefined-update" returned undefined from update/)
|
|
})
|
|
|
|
it('updates the same Context for later start matches and reselects an earlier prepended start', () => {
|
|
const start = vi.fn((_context: ConversationNodeContext<number[]>, match: ConversationMatch) => [match.event.seq])
|
|
const update = vi.fn((context: { state: number[] }, match: ConversationMatch) => [...context.state, match.event.seq])
|
|
const definition: ConversationNodeDefinition<number[]> = {
|
|
kind: 'single-start',
|
|
match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
|
|
start,
|
|
update,
|
|
target: 'test',
|
|
buildViewNode: context => node(context, context.state),
|
|
}
|
|
const assembler = new ConversationNodeAssembler(
|
|
new TestEventDefinitions([definition]),
|
|
new TestViewDefinitions([testView()]),
|
|
)
|
|
assembler.replaceWindow([
|
|
input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' })),
|
|
], false)
|
|
assembler.flush()
|
|
|
|
assembler.append(
|
|
input(at(SessionSeq(2), 'command/run', { commandId: 'two', name: 'x' })),
|
|
)
|
|
assembler.flush()
|
|
expect(start).toHaveBeenCalledOnce()
|
|
expect(update).toHaveBeenCalledOnce()
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual([1, 2])
|
|
assembler.prepend([input(at(SessionSeq(0), 'command/run', { commandId: 'zero', name: 'x' }))], false)
|
|
assembler.flush()
|
|
expect(start).toHaveBeenCalledTimes(2)
|
|
expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual([0, 1, 2])
|
|
})
|
|
})
|