conversation-assembler.client.spec.ts 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542
  1. import { describe, expect, it, vi } from 'vitest'
  2. import type {
  3. SessionEventLike, SessionEventLikeEntry, SessionLiveEventEntry,
  4. } from '@deepseek-ai/dsh-api-session-controller/client'
  5. import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
  6. import type { StreamChunk } from '@deepseek-ai/dsh-llm'
  7. import { SessionSeq } from '@deepseek-ai/dsh-session/types'
  8. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  9. import {
  10. ConversationLocationIndex,
  11. ConversationNodeAssembler as RuntimeConversationNodeAssembler,
  12. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  13. import type {
  14. ConversationMatch, ConversationNodeContext,
  15. ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
  16. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  17. interface ScopeProbeStepData {
  18. readonly value: number
  19. }
  20. interface ScopeProbeTurnData {
  21. readonly valueSeenFromStep: number
  22. }
  23. declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
  24. interface ConversationStepDataMap {
  25. 'scope-probe': ScopeProbeStepData
  26. }
  27. interface ConversationTurnDataMap {
  28. 'scope-probe': ScopeProbeTurnData
  29. }
  30. }
  31. interface TestSnapshot {
  32. readonly order: readonly string[]
  33. readonly nodes: ReadonlyMap<string, ConversationViewNode>
  34. }
  35. class TestEventDefinitions {
  36. readonly definitions: readonly ConversationNodeDefinition[]
  37. readonly fallback: ConversationNodeDefinition | undefined
  38. constructor(
  39. definitions: readonly ConversationNodeDefinition[],
  40. fallback?: ConversationNodeDefinition,
  41. ) {
  42. this.definitions = definitions
  43. this.fallback = fallback
  44. }
  45. entries(): readonly ConversationNodeDefinition[] {
  46. return this.definitions
  47. }
  48. fallbackEntry(): ConversationNodeDefinition | undefined {
  49. return this.fallback
  50. }
  51. }
  52. class TestViewDefinitions {
  53. constructor(readonly definitions: readonly ConversationViewDefinition[]) {}
  54. entries(): readonly ConversationViewDefinition[] {
  55. return this.definitions
  56. }
  57. }
  58. class ConversationNodeAssembler extends RuntimeConversationNodeAssembler {
  59. constructor(events: TestEventDefinitions, views: TestViewDefinitions) {
  60. super(events, views)
  61. for (const view of views.entries()) this.activateTarget(view.target)
  62. }
  63. }
  64. function testView(
  65. apply = vi.fn(),
  66. ): ConversationViewDefinition<ConversationViewNode, TestSnapshot> {
  67. return {
  68. target: 'test',
  69. create: () => {
  70. let current: TestSnapshot = { order: [], nodes: new Map() }
  71. return {
  72. empty: current,
  73. replace: ({ nodes }) => {
  74. current = { order: nodes.map(node => node.key), nodes: new Map(nodes.map(node => [node.key, node])) }
  75. return current
  76. },
  77. apply: ({ upserts }) => {
  78. apply(upserts)
  79. const nodes = new Map(current.nodes)
  80. const order = [...current.order]
  81. for (const node of upserts) {
  82. if (!nodes.has(node.key)) order.push(node.key)
  83. nodes.set(node.key, node)
  84. }
  85. current = { order, nodes }
  86. return current
  87. },
  88. }
  89. },
  90. }
  91. }
  92. function trackedView(target: string) {
  93. const replace = vi.fn(({ nodes }: { readonly nodes: readonly ConversationViewNode[] }) => nodes)
  94. const apply = vi.fn(({ upserts }: { readonly upserts: readonly ConversationViewNode[] }) => upserts)
  95. const create = vi.fn(() => ({
  96. empty: [] as readonly ConversationViewNode[],
  97. replace,
  98. apply,
  99. }))
  100. const definition: ConversationViewDefinition<ConversationViewNode, readonly ConversationViewNode[]> = {
  101. target,
  102. create,
  103. }
  104. return { definition, create, replace, apply }
  105. }
  106. function at(seq: SessionSeq, type: string, data: unknown): SessionEvent {
  107. return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent
  108. }
  109. function input(event: SessionEvent): SessionLiveEventEntry {
  110. return { type: 'event', event }
  111. }
  112. function transientChunk(
  113. seq: number,
  114. turn: number,
  115. step: number,
  116. chunk: StreamChunk,
  117. ): SessionEventLikeEntry {
  118. return {
  119. type: 'transient',
  120. event: {
  121. type: 'assistant/live-chunk',
  122. seq,
  123. time: 1_700_000_000_000 + seq,
  124. data: { attemptId: LlmAttemptId('test-attempt'), turn, step, chunk },
  125. },
  126. }
  127. }
  128. function testSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined {
  129. return assembler.snapshot('test') as TestSnapshot | undefined
  130. }
  131. function node(
  132. context: Parameters<NonNullable<ConversationNodeDefinition['buildViewNode']>>[0],
  133. data: unknown,
  134. ): ConversationViewNode {
  135. return {
  136. key: context.key,
  137. kind: context.kind,
  138. id: context.id,
  139. target: 'test',
  140. data,
  141. }
  142. }
  143. function fallbackDefinition(start: () => string): ConversationNodeDefinition<string> {
  144. return {
  145. kind: 'fallback',
  146. target: 'test',
  147. match: event => ({ id: String(event.seq), role: 'start' }),
  148. start,
  149. update: context => context.state,
  150. buildViewNode: context => node(context, context.state),
  151. }
  152. }
  153. describe('ConversationNodeAssembler', () => {
  154. it('publishes Location data through stable per-key sources', () => {
  155. const index = new ConversationLocationIndex()
  156. const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 })
  157. const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })
  158. index.rebuild([input(turnStart), input(stepStart)])
  159. const location = index.locationOf(stepStart)
  160. if (location.kind !== 'step') throw new Error('scope probe requires a Step Location')
  161. const source = location.step.data.source('scope-probe')
  162. const listener = vi.fn()
  163. source.subscribe(listener)
  164. const initial = { value: 1 }
  165. expect(location.step.data.source('scope-probe')).toBe(source)
  166. expect(source.getSnapshot()).toBeUndefined()
  167. expect(index.replaceData([{
  168. owner: 'scope-probe:1:1',
  169. data: { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: initial },
  170. }])).toBe(true)
  171. expect(source.getSnapshot()).toBe(initial)
  172. expect(listener).not.toHaveBeenCalled()
  173. index.publishData()
  174. expect(listener).toHaveBeenCalledOnce()
  175. listener.mockClear()
  176. index.replaceData([])
  177. index.replaceData([{
  178. owner: 'scope-probe:1:1',
  179. data: { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: initial },
  180. }])
  181. index.publishData()
  182. expect(listener).not.toHaveBeenCalled()
  183. const changed = { value: 2 }
  184. index.replaceData([{
  185. owner: 'scope-probe:1:1',
  186. data: { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: changed },
  187. }])
  188. index.publishData()
  189. expect(source.getSnapshot()).toBe(changed)
  190. expect(listener).toHaveBeenCalledOnce()
  191. index.replaceData([])
  192. index.publishData()
  193. expect(source.getSnapshot()).toBeUndefined()
  194. expect(listener).toHaveBeenCalledTimes(2)
  195. })
  196. it('reports a replacement only when an active target has a registered builder', () => {
  197. const assembler = new RuntimeConversationNodeAssembler(
  198. new TestEventDefinitions([]),
  199. new TestViewDefinitions([]),
  200. )
  201. expect(assembler.activateTarget('registered-later')).toBe(false)
  202. assembler.replaceWindow([], false)
  203. expect(assembler.flush()).toBe(false)
  204. })
  205. it('updates only active targets and never deactivates one after first use', () => {
  206. type State = { readonly updates: number }
  207. const definition = (
  208. target: string,
  209. buildViewNode: NonNullable<ConversationNodeDefinition<State>['buildViewNode']>,
  210. ): ConversationNodeDefinition<State> => ({
  211. kind: `active-${target}`,
  212. target,
  213. match: (event) => {
  214. const type = event.type as string
  215. if (type === 'active/start') return { id: 'one', role: 'start' }
  216. if (type === 'active/update') return { id: 'one', role: 'update' }
  217. return null
  218. },
  219. start: () => ({ updates: 0 }),
  220. update: context => ({ updates: context.state.updates + 1 }),
  221. buildViewNode,
  222. })
  223. const chat = trackedView('chat')
  224. const trajectory = trackedView('trajectory')
  225. const build = (target: string) => vi.fn((context: ConversationNodeContext<State>): ConversationViewNode => ({
  226. key: context.key,
  227. kind: context.kind,
  228. id: context.id,
  229. target,
  230. data: context.state,
  231. }))
  232. const buildChat = build('chat')
  233. const buildTrajectory = build('trajectory')
  234. const assembler = new RuntimeConversationNodeAssembler(
  235. new TestEventDefinitions([
  236. definition('chat', buildChat),
  237. definition('trajectory', buildTrajectory),
  238. ]),
  239. new TestViewDefinitions([chat.definition, trajectory.definition]),
  240. )
  241. assembler.replaceWindow([input(at(SessionSeq(1), 'active/start', {}))], false)
  242. expect(assembler.flush()).toBe(false)
  243. expect(chat.create).not.toHaveBeenCalled()
  244. expect(trajectory.create).not.toHaveBeenCalled()
  245. expect(buildChat).not.toHaveBeenCalled()
  246. expect(buildTrajectory).not.toHaveBeenCalled()
  247. expect(assembler.activateTarget('chat')).toBe(true)
  248. expect(chat.replace).toHaveBeenCalledOnce()
  249. expect(trajectory.replace).not.toHaveBeenCalled()
  250. expect(buildChat).toHaveBeenCalledOnce()
  251. expect(buildTrajectory).not.toHaveBeenCalled()
  252. assembler.append(input(at(SessionSeq(2), 'active/update', {})))
  253. expect(assembler.flush()).toBe(true)
  254. expect(chat.apply).toHaveBeenCalledOnce()
  255. expect(trajectory.apply).not.toHaveBeenCalled()
  256. expect(buildTrajectory).not.toHaveBeenCalled()
  257. expect(assembler.activateTarget('trajectory')).toBe(true)
  258. expect(trajectory.replace).toHaveBeenCalledOnce()
  259. expect((assembler.snapshot('trajectory') as readonly ConversationViewNode[])
  260. .map(node => node.data)).toEqual([{ updates: 1 }])
  261. assembler.append(input(at(SessionSeq(3), 'active/update', {})))
  262. expect(assembler.flush()).toBe(true)
  263. expect(chat.apply).toHaveBeenCalledTimes(2)
  264. expect(trajectory.apply).toHaveBeenCalledOnce()
  265. expect(assembler.activateTarget('chat')).toBe(false)
  266. expect(assembler.activateTarget('trajectory')).toBe(false)
  267. expect(chat.replace).toHaveBeenCalledOnce()
  268. expect(trajectory.replace).toHaveBeenCalledOnce()
  269. })
  270. it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
  271. const starts = vi.fn((
  272. _context: ConversationNodeContext<{ callSeq: number; results: number }>,
  273. match: ConversationMatch,
  274. ) => ({ callSeq: match.event.seq, results: 0 }))
  275. const updates = vi.fn((context: { state: { callSeq: number; results: number } }) => ({
  276. ...context.state,
  277. results: context.state.results + 1,
  278. }))
  279. const definition: ConversationNodeDefinition<{ callSeq: number; results: number }> = {
  280. kind: 'tool',
  281. match: (event) => {
  282. if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
  283. if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
  284. return null
  285. },
  286. start: starts,
  287. update: updates,
  288. target: 'test',
  289. buildViewNode: context => node(context, context.state),
  290. }
  291. const assembler = new ConversationNodeAssembler(
  292. new TestEventDefinitions([definition]),
  293. new TestViewDefinitions([testView()]),
  294. )
  295. assembler.replaceWindow([
  296. input(at(SessionSeq(1), 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })),
  297. input(at(SessionSeq(2), 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })),
  298. ], false)
  299. assembler.flush()
  300. starts.mockClear()
  301. assembler.append(input(at(SessionSeq(3), 'tool/result', {
  302. turn: 1,
  303. step: 1,
  304. message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
  305. })))
  306. assembler.flush()
  307. expect(starts).not.toHaveBeenCalled()
  308. expect(updates).toHaveBeenCalledOnce()
  309. const snapshot = testSnapshot(assembler)
  310. expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([
  311. { callSeq: 1, results: 1 },
  312. { callSeq: 2, results: 0 },
  313. ])
  314. })
  315. it('keeps one Match collection while a long Context appends without replay', () => {
  316. const starts = vi.fn(() => 0)
  317. const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
  318. context.state + 1
  319. ))
  320. const matchCollections = new Set<readonly ConversationMatch[]>()
  321. const definition: ConversationNodeDefinition<number> = {
  322. kind: 'append-linear',
  323. match: (event) => {
  324. const type: string = event.type
  325. if (type === 'linear/start') return { id: 'one', role: 'start' }
  326. if (type === 'linear/update') return { id: 'one', role: 'update' }
  327. return null
  328. },
  329. start: (context) => {
  330. matchCollections.add(context.matches)
  331. return starts()
  332. },
  333. update: (context) => {
  334. matchCollections.add(context.matches)
  335. return updates(context)
  336. },
  337. target: 'test',
  338. buildViewNode: context => node(context, context.state),
  339. }
  340. const assembler = new ConversationNodeAssembler(
  341. new TestEventDefinitions([definition]),
  342. new TestViewDefinitions([testView()]),
  343. )
  344. assembler.replaceWindow([input(at(SessionSeq(1), 'linear/start', {}))], false)
  345. starts.mockClear()
  346. for (let seq = 2; seq <= 1_001; seq++) {
  347. assembler.append(input(at(SessionSeq(seq), 'linear/update', {})))
  348. }
  349. assembler.flush()
  350. expect(starts).not.toHaveBeenCalled()
  351. expect(updates).toHaveBeenCalledTimes(1_000)
  352. expect(matchCollections.size).toBe(1)
  353. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000)
  354. })
  355. it('keeps one transient Match through replace, Location replay, and Registry rebuild', () => {
  356. interface State {
  357. readonly updates: readonly string[]
  358. readonly transientStatus: string | undefined
  359. }
  360. const matches = vi.fn((event: SessionEventLike) => {
  361. if (event.type === 'step/start') return { id: '2:3', role: 'start' as const }
  362. if ((event.type as string) === 'probe/update'
  363. || event.type === 'assistant/live-chunk') {
  364. return { id: '2:3', role: 'update' as const }
  365. }
  366. return null
  367. })
  368. const passiveMatches = vi.fn(() => null)
  369. const updates = vi.fn((
  370. context: ConversationNodeContext<State> & { readonly state: State },
  371. match: ConversationMatch,
  372. ): State => {
  373. if (match.event.type === 'assistant/live-chunk') {
  374. return {
  375. ...context.state,
  376. updates: [
  377. ...context.state.updates,
  378. `transient:${String(match.event.seq)}:${match.event.data.chunk.type}`,
  379. ],
  380. transientStatus: match.location.kind === 'step'
  381. ? match.location.step.status
  382. : match.location.kind,
  383. }
  384. }
  385. return {
  386. ...context.state,
  387. updates: [...context.state.updates, `event:${String(match.event.seq)}`],
  388. }
  389. })
  390. const definition: ConversationNodeDefinition<State> = {
  391. kind: 'transient-probe',
  392. match: matches,
  393. start: () => ({ updates: [], transientStatus: undefined }),
  394. update: updates,
  395. target: 'test',
  396. buildViewNode: context => context.state === undefined
  397. ? null
  398. : node(context, {
  399. ...context.state,
  400. matches: context.matches.map(match => ({
  401. type: match.event.type,
  402. seq: match.event.seq,
  403. })),
  404. }),
  405. }
  406. const passive: ConversationNodeDefinition<null> = {
  407. kind: 'transient-passive',
  408. match: passiveMatches,
  409. start: () => null,
  410. update: context => context.state,
  411. }
  412. const delta = transientChunk(12.5, 2, 3, { type: 'text-delta', index: 0, text: 'abc' })
  413. const inputs: SessionEventLikeEntry[] = [
  414. input(at(SessionSeq(10), 'step/start', { turn: 2, step: 3 })),
  415. input(at(SessionSeq(11), 'probe/update', { turn: 2, step: 3 })),
  416. delta,
  417. input(at(SessionSeq(15), 'probe/update', { turn: 2, step: 3 })),
  418. ]
  419. const assembler = new ConversationNodeAssembler(
  420. new TestEventDefinitions([definition, passive]),
  421. new TestViewDefinitions([testView()]),
  422. )
  423. assembler.replaceWindow(inputs, false)
  424. assembler.flush()
  425. expect(matches).toHaveBeenCalledTimes(4)
  426. expect(passiveMatches).toHaveBeenCalledTimes(4)
  427. expect(updates).toHaveBeenCalledTimes(3)
  428. expect(updates.mock.calls.filter(([, match]) => (
  429. match.event.type === 'assistant/live-chunk'
  430. ))).toHaveLength(1)
  431. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual({
  432. updates: ['event:11', 'transient:12.5:text-delta', 'event:15'],
  433. transientStatus: 'open',
  434. matches: [
  435. { type: 'step/start', seq: 10 },
  436. { type: 'probe/update', seq: 11 },
  437. { type: 'assistant/live-chunk', seq: 12.5 },
  438. { type: 'probe/update', seq: 15 },
  439. ],
  440. })
  441. assembler.append(input(at(SessionSeq(16), 'step/end', { turn: 2, step: 3 })))
  442. assembler.flush()
  443. expect(updates.mock.calls.filter(([, match]) => (
  444. match.event.type === 'assistant/live-chunk'
  445. ))).toHaveLength(2)
  446. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toMatchObject({
  447. updates: ['event:11', 'transient:12.5:text-delta', 'event:15'],
  448. transientStatus: 'closed',
  449. })
  450. matches.mockClear()
  451. passiveMatches.mockClear()
  452. updates.mockClear()
  453. assembler.rebuildRegistry()
  454. assembler.flush()
  455. expect(matches).toHaveBeenCalledTimes(5)
  456. expect(passiveMatches).toHaveBeenCalledTimes(5)
  457. expect(updates).toHaveBeenCalledTimes(3)
  458. expect(updates.mock.calls.filter(([, match]) => (
  459. match.event.type === 'assistant/live-chunk'
  460. ))).toHaveLength(1)
  461. })
  462. it('settles one Assistant attempt without replacing unrelated Contexts', () => {
  463. interface State { readonly events: readonly string[] }
  464. const definition: ConversationNodeDefinition<State> = {
  465. kind: 'assistant-settlement',
  466. target: 'test',
  467. match: (event) => {
  468. if (event.type === 'step/start') {
  469. return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
  470. }
  471. if (event.type === 'assistant/live-chunk'
  472. || event.type === 'assistant/message'
  473. || event.type === 'assistant/attempt'
  474. || event.type === 'llm/retry') {
  475. return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
  476. }
  477. return null
  478. },
  479. start: () => ({ events: [] }),
  480. update: (context, match) => ({
  481. events: [...context.state.events, match.event.type],
  482. }),
  483. buildViewNode: context => context.state === undefined
  484. ? null
  485. : node(context, context.state.events),
  486. }
  487. const apply = vi.fn()
  488. const assembler = new ConversationNodeAssembler(
  489. new TestEventDefinitions([definition]),
  490. new TestViewDefinitions([testView(apply)]),
  491. )
  492. const firstStart = input(at(SessionSeq(10), 'step/start', { turn: 2, step: 3 }))
  493. const secondStart = input(at(SessionSeq(11), 'step/start', { turn: 2, step: 4 }))
  494. const delta = transientChunk(11.5, 2, 3, { type: 'text-delta', index: 0, text: 'abc' })
  495. const later = input(at(SessionSeq(13), 'llm/retry', { turn: 2, step: 3 }))
  496. assembler.replaceWindow([firstStart, secondStart, delta, later], false)
  497. assembler.flush()
  498. const before = [...testSnapshot(assembler)?.nodes.values() ?? []]
  499. const unaffected = before.find(candidate => candidate.id === '2:4')
  500. expect(unaffected).toBeDefined()
  501. apply.mockClear()
  502. const settlementEvent = at(SessionSeq(12), 'assistant/message', {
  503. turn: 2,
  504. step: 3,
  505. message: { role: 'assistant', content: [], source: { kind: 'model', provider: 'p', model: 'm' } },
  506. stream: [],
  507. })
  508. if (settlementEvent.type !== 'assistant/message') throw new Error('expected Assistant settlement')
  509. const settlement = { type: 'event' as const, event: settlementEvent }
  510. expect(assembler.settleAssistant(LlmAttemptId('test-attempt'), settlement)).toBe('immediate')
  511. assembler.flush()
  512. const after = [...testSnapshot(assembler)?.nodes.values() ?? []]
  513. expect(after.find(candidate => candidate.id === '2:3')?.data)
  514. .toEqual(['assistant/message', 'llm/retry'])
  515. expect(after.find(candidate => candidate.id === '2:4')).toBe(unaffected)
  516. expect(apply).toHaveBeenCalledOnce()
  517. expect(apply.mock.calls[0]?.[0]).toHaveLength(1)
  518. assembler.append(transientChunk(13.5, 2, 3, { type: 'reasoning-delta', index: 0, text: 'x' }))
  519. assembler.flush()
  520. expect(assembler.settleAssistant(LlmAttemptId('test-attempt'))).toBe('immediate')
  521. assembler.flush()
  522. expect([...testSnapshot(assembler)?.nodes.values() ?? []]
  523. .find(candidate => candidate.id === '2:3')?.data)
  524. .toEqual(['assistant/message', 'llm/retry'])
  525. })
  526. it('replays one pending transient Match after prepend supplies its durable start', () => {
  527. const starts = vi.fn(() => ({ batches: 0, status: 'unresolved' }))
  528. const updates = vi.fn((
  529. context: ConversationNodeContext<{ batches: number; status: string }> & {
  530. readonly state: { batches: number; status: string }
  531. },
  532. match: ConversationMatch,
  533. ) => ({
  534. batches: context.state.batches + 1,
  535. status: match.location.kind === 'step' ? match.location.step.status : match.location.kind,
  536. }))
  537. const definition: ConversationNodeDefinition<{ batches: number; status: string }> = {
  538. kind: 'transient-pending',
  539. match: (event) => {
  540. if (event.type === 'step/start') {
  541. return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'start' }
  542. }
  543. if (event.type === 'assistant/live-chunk') {
  544. return { id: `${String(event.data.turn)}:${String(event.data.step)}`, role: 'update' }
  545. }
  546. return null
  547. },
  548. start: starts,
  549. update: updates,
  550. target: 'test',
  551. buildViewNode: context => context.state === undefined
  552. ? null
  553. : node(context, {
  554. ...context.state,
  555. matches: context.matches.map(match => [match.event.type, match.event.seq]),
  556. }),
  557. }
  558. const assembler = new ConversationNodeAssembler(
  559. new TestEventDefinitions([definition]),
  560. new TestViewDefinitions([testView()]),
  561. )
  562. const delta = transientChunk(21, 4, 5, { type: 'reasoning-delta', index: 0, text: 'x' })
  563. assembler.replaceWindow([delta], true)
  564. assembler.flush()
  565. expect(starts).not.toHaveBeenCalled()
  566. expect(updates).not.toHaveBeenCalled()
  567. expect(testSnapshot(assembler)?.order).toEqual([])
  568. assembler.prepend([
  569. input(at(SessionSeq(20), 'step/start', { turn: 4, step: 5 })),
  570. ], false)
  571. assembler.flush()
  572. expect(starts).toHaveBeenCalledOnce()
  573. expect(updates).toHaveBeenCalledOnce()
  574. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual({
  575. batches: 1,
  576. status: 'open',
  577. matches: [['step/start', 20], ['assistant/live-chunk', 21]],
  578. })
  579. })
  580. it('rejects a transient event classified as a Context start', () => {
  581. const definition: ConversationNodeDefinition<null> = {
  582. kind: 'invalid-transient-start',
  583. match: event => event.type === 'assistant/live-chunk'
  584. ? { id: 'one', role: 'start' }
  585. : null,
  586. start: () => null,
  587. update: context => context.state,
  588. }
  589. const assembler = new ConversationNodeAssembler(
  590. new TestEventDefinitions([definition]),
  591. new TestViewDefinitions([testView()]),
  592. )
  593. const delta = transientChunk(1, 1, 1, { type: 'text-delta', index: 0, text: 'abc' })
  594. expect(() => assembler.replaceWindow([delta], false)).toThrow(
  595. 'conversation Context 23:invalid-transient-startone received a transient start Match',
  596. )
  597. })
  598. it('merges an older page and replays its affected Context once', () => {
  599. const starts = vi.fn(() => 0)
  600. const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
  601. context.state + 1
  602. ))
  603. const definition: ConversationNodeDefinition<number> = {
  604. kind: 'prepend-linear',
  605. match: (event) => {
  606. const type: string = event.type
  607. if (type === 'linear/start') return { id: 'one', role: 'start' }
  608. if (type === 'linear/update') return { id: 'one', role: 'update' }
  609. return null
  610. },
  611. start: starts,
  612. update: updates,
  613. target: 'test',
  614. buildViewNode: context => node(context, context.state),
  615. }
  616. const assembler = new ConversationNodeAssembler(
  617. new TestEventDefinitions([definition]),
  618. new TestViewDefinitions([testView()]),
  619. )
  620. const current = Array.from({ length: 100 }, (_, index) => (
  621. input(at(SessionSeq(index + 102), 'linear/update', {}))
  622. ))
  623. assembler.replaceWindow(current, true)
  624. assembler.flush()
  625. expect(starts).not.toHaveBeenCalled()
  626. expect(updates).not.toHaveBeenCalled()
  627. const older = [
  628. input(at(SessionSeq(1), 'linear/start', {})),
  629. ...Array.from({ length: 100 }, (_, index) => (
  630. input(at(SessionSeq(index + 2), 'linear/update', {}))
  631. )),
  632. ]
  633. assembler.prepend(older, false)
  634. assembler.flush()
  635. expect(starts).toHaveBeenCalledOnce()
  636. expect(updates).toHaveBeenCalledTimes(200)
  637. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200)
  638. })
  639. it('collects an update before its start and replays it once prepend supplies the start', () => {
  640. const updates = vi.fn((context: { state: { settled: boolean } }) => ({ ...context.state, settled: true }))
  641. const definition: ConversationNodeDefinition<{ settled: boolean }> = {
  642. kind: 'tool',
  643. match: (event) => {
  644. if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
  645. if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
  646. return null
  647. },
  648. start: () => ({ settled: false }),
  649. update: updates,
  650. target: 'test',
  651. buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
  652. }
  653. const assembler = new ConversationNodeAssembler(
  654. new TestEventDefinitions([definition]),
  655. new TestViewDefinitions([testView()]),
  656. )
  657. assembler.replaceWindow([input(at(SessionSeq(10), 'tool/result', {
  658. turn: 1,
  659. step: 1,
  660. message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
  661. }))], true)
  662. assembler.flush()
  663. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  664. .toEqual({ pendingStart: true })
  665. assembler.prepend([input(at(SessionSeq(5), 'tool/call', {
  666. turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}',
  667. }))], false)
  668. assembler.flush()
  669. expect(updates).toHaveBeenCalledOnce()
  670. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  671. .toEqual({ settled: true })
  672. })
  673. it('rejects a Definition whose declared start follows an update in log order', () => {
  674. const definition: ConversationNodeDefinition<null> = {
  675. kind: 'invalid-lifecycle',
  676. match: event => event.type === 'turn/end'
  677. ? { id: 'one', role: 'start' }
  678. : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
  679. start: () => null,
  680. update: context => context.state,
  681. target: 'test',
  682. buildViewNode: () => null,
  683. }
  684. const assembler = new ConversationNodeAssembler(
  685. new TestEventDefinitions([definition]),
  686. new TestViewDefinitions([testView()]),
  687. )
  688. expect(() => assembler.replaceWindow([
  689. input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
  690. input(at(SessionSeq(2), 'turn/end', { turn: 1, reason: { kind: 'completed' } })),
  691. ], false)).toThrow('received an update before its start Match')
  692. })
  693. it('replays a window-gap reader when prepend supplies a nearer predecessor', () => {
  694. const source: ConversationNodeDefinition<number> = {
  695. kind: 'source',
  696. match: event => event.type === 'user/message'
  697. ? { id: String(event.data.id), role: 'start' }
  698. : null,
  699. start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
  700. update: context => context.state,
  701. target: 'test',
  702. buildViewNode: () => null,
  703. }
  704. const consumerStart = vi.fn((
  705. _context: Parameters<ConversationNodeDefinition<number>['start']>[0],
  706. _match: Parameters<ConversationNodeDefinition<number>['start']>[1],
  707. reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
  708. ) => reader.previous<number>('source')?.state ?? -1)
  709. const consumer: ConversationNodeDefinition<number> = {
  710. kind: 'consumer',
  711. match: event => event.type === 'assistant/message'
  712. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  713. : null,
  714. start: consumerStart,
  715. update: context => context.state,
  716. target: 'test',
  717. buildViewNode: context => node(context, context.state),
  718. }
  719. const assembler = new ConversationNodeAssembler(
  720. new TestEventDefinitions([source, consumer]),
  721. new TestViewDefinitions([testView()]),
  722. )
  723. assembler.replaceWindow([input(at(SessionSeq(10), 'assistant/message', {
  724. turn: 2, step: 1, message: { role: 'assistant', content: [] },
  725. stream: [],
  726. }))], true)
  727. assembler.flush()
  728. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
  729. assembler.prepend([input(at(SessionSeq(5), 'user/message', {
  730. id: 'm1', value: 7, content: [], source: { kind: 'user' },
  731. }))], false)
  732. assembler.flush()
  733. expect(consumerStart).toHaveBeenCalledTimes(2)
  734. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7)
  735. })
  736. it('keeps the predecessor index ordered across prepend and append', () => {
  737. const source: ConversationNodeDefinition<number> = {
  738. kind: 'source',
  739. match: event => event.type === 'user/message'
  740. ? { id: String(event.data.id), role: 'start' }
  741. : null,
  742. start: (_context, match) => match.event.seq,
  743. update: context => context.state,
  744. target: 'test',
  745. buildViewNode: () => null,
  746. }
  747. const consumer: ConversationNodeDefinition<number> = {
  748. kind: 'consumer',
  749. match: event => event.type === 'assistant/message'
  750. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  751. : null,
  752. start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
  753. update: context => context.state,
  754. target: 'test',
  755. buildViewNode: context => node(context, context.state),
  756. }
  757. const assembler = new ConversationNodeAssembler(
  758. new TestEventDefinitions([source, consumer]),
  759. new TestViewDefinitions([testView()]),
  760. )
  761. assembler.replaceWindow([
  762. input(at(SessionSeq(40), 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })),
  763. input(at(SessionSeq(50), 'assistant/message', {
  764. turn: 1, step: 1, message: { role: 'assistant', content: [] },
  765. stream: [],
  766. })),
  767. ], true)
  768. assembler.flush()
  769. assembler.prepend([
  770. input(at(SessionSeq(10), 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })),
  771. input(at(SessionSeq(30), 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })),
  772. ], false)
  773. assembler.flush()
  774. assembler.append(input(at(SessionSeq(60), 'user/message', {
  775. id: 'm60', content: [], source: { kind: 'user' },
  776. })))
  777. assembler.append(input(at(SessionSeq(70), 'assistant/message', {
  778. turn: 2, step: 1, message: { role: 'assistant', content: [] },
  779. stream: [],
  780. })))
  781. assembler.flush()
  782. expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
  783. .toEqual([40, 60])
  784. })
  785. it('replays a window-gap reader when an empty prepend closes the unknown prefix', () => {
  786. const consumerStart = vi.fn((
  787. _context: Parameters<ConversationNodeDefinition<number>['start']>[0],
  788. _match: Parameters<ConversationNodeDefinition<number>['start']>[1],
  789. reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
  790. ) => reader.previous<number>('source')?.state ?? -1)
  791. const consumer: ConversationNodeDefinition<number> = {
  792. kind: 'consumer',
  793. match: event => event.type === 'assistant/message'
  794. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  795. : null,
  796. start: consumerStart,
  797. update: context => context.state,
  798. target: 'test',
  799. buildViewNode: context => node(context, context.state),
  800. }
  801. const assembler = new ConversationNodeAssembler(
  802. new TestEventDefinitions([consumer]),
  803. new TestViewDefinitions([testView()]),
  804. )
  805. assembler.replaceWindow([input(at(SessionSeq(10), 'assistant/message', {
  806. turn: 2, step: 1, message: { role: 'assistant', content: [] },
  807. stream: [],
  808. }))], true)
  809. assembler.flush()
  810. expect(assembler.prepend([], false)).toBe('immediate')
  811. assembler.flush()
  812. expect(consumerStart).toHaveBeenCalledTimes(2)
  813. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
  814. })
  815. it('replays direct dependents when an append revises their predecessor Context', () => {
  816. const source: ConversationNodeDefinition<number> = {
  817. kind: 'source',
  818. match: (event) => {
  819. if (event.type === 'user/message') return { id: 'one', role: 'start' }
  820. if ((event.type as string) === 'source/update') return { id: 'one', role: 'update' }
  821. return null
  822. },
  823. start: () => 1,
  824. update: (_context, match) => (match.event.data as unknown as { value: number }).value,
  825. target: 'test',
  826. buildViewNode: () => null,
  827. }
  828. const consumerStart = vi.fn((
  829. _context: Parameters<ConversationNodeDefinition<number>['start']>[0],
  830. _match: Parameters<ConversationNodeDefinition<number>['start']>[1],
  831. reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
  832. ) => reader.previous<number>('source')?.state ?? -1)
  833. const consumer: ConversationNodeDefinition<number> = {
  834. kind: 'consumer',
  835. match: event => event.type === 'assistant/message'
  836. ? { id: 'one', role: 'start' }
  837. : null,
  838. start: consumerStart,
  839. update: context => context.state,
  840. target: 'test',
  841. buildViewNode: context => node(context, context.state),
  842. }
  843. const assembler = new ConversationNodeAssembler(
  844. new TestEventDefinitions([source, consumer]),
  845. new TestViewDefinitions([testView()]),
  846. )
  847. assembler.replaceWindow([
  848. input(at(SessionSeq(1), 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
  849. input(at(SessionSeq(2), 'assistant/message', {
  850. turn: 1, step: 1, message: { role: 'assistant', content: [] }, stream: [],
  851. })),
  852. ], false)
  853. assembler.flush()
  854. expect(assembler.append(input(at(SessionSeq(3), 'source/update', { value: 2 })))).toBe('immediate')
  855. assembler.flush()
  856. expect(consumerStart).toHaveBeenCalledTimes(2)
  857. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2)
  858. })
  859. it('replays a transitive dependency closure in start order', () => {
  860. const sourceA: ConversationNodeDefinition<number> = {
  861. kind: 'diamond-a',
  862. match: (event) => {
  863. if (event.type === 'user/message') return { id: 'one', role: 'start' }
  864. if ((event.type as string) === 'diamond/a') return { id: 'one', role: 'update' }
  865. return null
  866. },
  867. start: () => 1,
  868. update: (_context, match) => (match.event.data as unknown as { value: number }).value,
  869. target: 'test',
  870. buildViewNode: () => null,
  871. }
  872. const sourceX: ConversationNodeDefinition<number> = {
  873. kind: 'diamond-x',
  874. match: (event) => {
  875. if (event.type === 'turn/start') return { id: 'one', role: 'start' }
  876. if ((event.type as string) === 'diamond/x') return { id: 'one', role: 'update' }
  877. return null
  878. },
  879. start: () => 10,
  880. update: (_context, match) => (match.event.data as unknown as { value: number }).value,
  881. target: 'test',
  882. buildViewNode: () => null,
  883. }
  884. const middle: ConversationNodeDefinition<number> = {
  885. kind: 'diamond-b',
  886. match: event => event.type === 'assistant/message'
  887. ? { id: 'one', role: 'start' }
  888. : null,
  889. start: (_context, _match, reader) => (
  890. (reader.previous<number>('diamond-a')?.state ?? 0)
  891. + (reader.previous<number>('diamond-x')?.state ?? 0)
  892. ),
  893. update: context => context.state,
  894. target: 'test',
  895. buildViewNode: context => node(context, context.state),
  896. }
  897. const consumer: ConversationNodeDefinition<number> = {
  898. kind: 'diamond-c',
  899. match: event => event.type === 'tool/call'
  900. ? { id: 'one', role: 'start' }
  901. : null,
  902. start: (_context, _match, reader) => (
  903. (reader.previous<number>('diamond-a')?.state ?? 0) * 100
  904. + (reader.previous<number>('diamond-b')?.state ?? 0)
  905. ),
  906. update: context => context.state,
  907. target: 'test',
  908. buildViewNode: context => node(context, context.state),
  909. }
  910. const assembler = new ConversationNodeAssembler(
  911. new TestEventDefinitions([sourceA, sourceX, middle, consumer]),
  912. new TestViewDefinitions([testView()]),
  913. )
  914. assembler.replaceWindow([
  915. input(at(SessionSeq(1), 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
  916. input(at(SessionSeq(2), 'turn/start', { turn: 1 })),
  917. input(at(SessionSeq(3), 'assistant/message', {
  918. turn: 1, step: 1, message: { role: 'assistant', content: [] }, stream: [],
  919. })),
  920. input(at(SessionSeq(4), 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })),
  921. ], false)
  922. assembler.append(input(at(SessionSeq(5), 'diamond/x', { value: 20 })))
  923. assembler.append(input(at(SessionSeq(6), 'diamond/a', { value: 2 })))
  924. assembler.flush()
  925. const value = [...testSnapshot(assembler)?.nodes.values() ?? []]
  926. .find(candidate => candidate.kind === 'diamond-c')
  927. expect(value?.data).toBe(222)
  928. })
  929. it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => {
  930. const apply = vi.fn()
  931. const starts = vi.fn((
  932. _context: Parameters<ConversationNodeDefinition<string>['start']>[0],
  933. match: Parameters<ConversationNodeDefinition<string>['start']>[1],
  934. ) => match.location.kind === 'step' ? match.location.step.status : 'missing')
  935. const definition: ConversationNodeDefinition<string> = {
  936. kind: 'step',
  937. match: event => event.type === 'step/start'
  938. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  939. : null,
  940. start: starts,
  941. update: context => context.state,
  942. target: 'test',
  943. buildViewNode: context => node(context, context.state),
  944. }
  945. const assembler = new ConversationNodeAssembler(
  946. new TestEventDefinitions([definition]),
  947. new TestViewDefinitions([testView(apply)]),
  948. )
  949. assembler.replaceWindow([
  950. input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
  951. input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
  952. ], false)
  953. assembler.flush()
  954. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open')
  955. assembler.append(input(at(SessionSeq(3), 'step/end', { turn: 1, step: 1 })))
  956. assembler.flush()
  957. expect(starts).toHaveBeenCalledTimes(2)
  958. expect(apply).toHaveBeenCalledOnce()
  959. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed')
  960. })
  961. it('lets one Context publish Step and Turn data in phase order', () => {
  962. interface State {
  963. readonly turn: number
  964. readonly step: number
  965. readonly value: number
  966. }
  967. const definition: ConversationNodeDefinition<State> = {
  968. kind: 'scope-probe',
  969. match: (event) => {
  970. if (event.type === 'step/start') {
  971. return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  972. }
  973. if ((event.type as string) === 'scope-probe/update') {
  974. return { id: '1:1', role: 'update' }
  975. }
  976. return null
  977. },
  978. start: (_context, match) => {
  979. if (match.event.type !== 'step/start') throw new Error('scope probe requires step/start')
  980. return { turn: match.event.data.turn, step: match.event.data.step, value: 1 }
  981. },
  982. update: (_context, match) => ({
  983. turn: 1,
  984. step: 1,
  985. value: (match.event.data as unknown as { value: number }).value,
  986. }),
  987. buildLocationData: (context, scope) => {
  988. const state = context.state
  989. if (state === undefined) return null
  990. if (scope === 'step') {
  991. return {
  992. kind: 'step',
  993. turn: state.turn,
  994. step: state.step,
  995. key: 'scope-probe',
  996. value: { value: state.value },
  997. }
  998. }
  999. const location = context.start?.location
  1000. const stepValue = location?.kind === 'step'
  1001. ? location.step.data.get('scope-probe')?.value
  1002. : undefined
  1003. return {
  1004. kind: 'turn',
  1005. turn: state.turn,
  1006. key: 'scope-probe',
  1007. value: { valueSeenFromStep: stepValue ?? -1 },
  1008. }
  1009. },
  1010. target: 'test',
  1011. buildViewNode: (context) => {
  1012. const location = context.start?.location
  1013. if (location?.kind !== 'step') return null
  1014. return node(context, {
  1015. step: location.step.data.get('scope-probe')?.value,
  1016. turn: location.turn.data.get('scope-probe')?.valueSeenFromStep,
  1017. })
  1018. },
  1019. }
  1020. const assembler = new ConversationNodeAssembler(
  1021. new TestEventDefinitions([definition]),
  1022. new TestViewDefinitions([testView()]),
  1023. )
  1024. assembler.replaceWindow([
  1025. input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
  1026. input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
  1027. ], false)
  1028. assembler.flush()
  1029. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  1030. .toEqual({ step: 1, turn: 1 })
  1031. assembler.append(input(at(SessionSeq(3), 'scope-probe/update', { turn: 1, step: 1, value: 2 })))
  1032. assembler.flush()
  1033. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  1034. .toEqual({ step: 2, turn: 2 })
  1035. })
  1036. it('does not republish an equivalent Location value', () => {
  1037. const apply = vi.fn()
  1038. const initialValue = { value: 1 }
  1039. const changedValue = { value: 2 }
  1040. const definition: ConversationNodeDefinition<{ readonly value: ScopeProbeStepData }> = {
  1041. kind: 'scope-probe',
  1042. match: (event) => {
  1043. if (event.type === 'step/start') return { id: '1:1', role: 'start' }
  1044. if ((event.type as string) === 'scope-probe/update') return { id: '1:1', role: 'update' }
  1045. return null
  1046. },
  1047. start: () => ({ value: initialValue }),
  1048. update: (_context, match) => ({
  1049. value: (match.event.data as unknown as { changed: boolean }).changed
  1050. ? changedValue
  1051. : initialValue,
  1052. }),
  1053. buildLocationData: (context, scope, previous) => {
  1054. if (scope !== 'step' || context.state === undefined) return null
  1055. if (previous?.kind === 'step'
  1056. && previous.turn === 1
  1057. && previous.step === 1
  1058. && previous.key === 'scope-probe'
  1059. && previous.value === context.state.value) return previous
  1060. return { kind: 'step', turn: 1, step: 1, key: 'scope-probe', value: context.state.value }
  1061. },
  1062. }
  1063. const assembler = new ConversationNodeAssembler(
  1064. new TestEventDefinitions([definition]),
  1065. new TestViewDefinitions([testView(apply)]),
  1066. )
  1067. assembler.replaceWindow([
  1068. input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
  1069. input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
  1070. ], false)
  1071. assembler.flush()
  1072. assembler.append(input(at(SessionSeq(3), 'scope-probe/update', { turn: 1, step: 1, changed: false })))
  1073. expect(assembler.flush()).toBe(false)
  1074. expect(apply).not.toHaveBeenCalled()
  1075. assembler.append(input(at(SessionSeq(4), 'scope-probe/update', { turn: 1, step: 1, changed: true })))
  1076. expect(assembler.flush()).toBe(true)
  1077. expect(apply).toHaveBeenCalledOnce()
  1078. })
  1079. it('updates existing turn Locations when their Step membership changes', () => {
  1080. const apply = vi.fn()
  1081. const definition: ConversationNodeDefinition<null> = {
  1082. kind: 'turn-probe',
  1083. match: event => event.type === 'turn/start'
  1084. ? { id: String(event.data.turn), role: 'start' }
  1085. : null,
  1086. start: () => null,
  1087. update: context => context.state,
  1088. target: 'test',
  1089. buildViewNode: context => node(context, context.start?.location.kind === 'turn'
  1090. ? context.start.location.turn.steps.length
  1091. : -1),
  1092. }
  1093. const assembler = new ConversationNodeAssembler(
  1094. new TestEventDefinitions([definition]),
  1095. new TestViewDefinitions([testView(apply)]),
  1096. )
  1097. assembler.replaceWindow([input(at(SessionSeq(1), 'turn/start', { turn: 1 }))], false)
  1098. assembler.flush()
  1099. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0)
  1100. assembler.append(input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })))
  1101. assembler.flush()
  1102. expect(apply).toHaveBeenCalledOnce()
  1103. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
  1104. })
  1105. it('publishes a changed timeline even when no business Definition claims the boundary', () => {
  1106. const apply = vi.fn()
  1107. const assembler = new ConversationNodeAssembler(
  1108. new TestEventDefinitions([]),
  1109. new TestViewDefinitions([testView(apply)]),
  1110. )
  1111. assembler.replaceWindow([], false)
  1112. assembler.flush()
  1113. assembler.append(input(at(SessionSeq(1), 'turn/start', { turn: 1 })))
  1114. assembler.flush()
  1115. expect(apply).toHaveBeenCalledOnce()
  1116. expect(testSnapshot(assembler)?.order).toEqual([])
  1117. })
  1118. it('clears the prior Step at a new Turn and honors explicit session ownership', () => {
  1119. const definition: ConversationNodeDefinition<null> = {
  1120. kind: 'location-probe',
  1121. match: (event) => {
  1122. if ((event.type as string) === 'command/run') {
  1123. return {
  1124. id: (event.data as unknown as { commandId: string }).commandId,
  1125. role: 'start',
  1126. }
  1127. }
  1128. if ((event.type as string) === 'compaction/start') {
  1129. return {
  1130. id: (event.data as unknown as { compactionId: string }).compactionId,
  1131. role: 'start',
  1132. }
  1133. }
  1134. return null
  1135. },
  1136. start: () => null,
  1137. update: context => context.state,
  1138. target: 'test',
  1139. buildViewNode: (context) => {
  1140. const location = context.start?.location
  1141. const data = location?.kind === 'step'
  1142. ? `step:${location.turn.turn}:${location.step.step}`
  1143. : location?.kind === 'turn' ? `turn:${location.turn.turn}` : location?.kind
  1144. return node(context, data)
  1145. },
  1146. }
  1147. const assembler = new ConversationNodeAssembler(
  1148. new TestEventDefinitions([definition]),
  1149. new TestViewDefinitions([testView()]),
  1150. )
  1151. assembler.replaceWindow([
  1152. input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
  1153. input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
  1154. input(at(SessionSeq(3), 'turn/start', { turn: 2 })),
  1155. input(at(SessionSeq(4), 'command/run', { commandId: 'command', name: 'x' })),
  1156. input(at(SessionSeq(5), 'compaction/start', { compactionId: 'compact', turn: null })),
  1157. ], false)
  1158. assembler.flush()
  1159. expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
  1160. .toEqual(['turn:2', 'session'])
  1161. })
  1162. it('assigns turn boundaries to the Turn even when a Step remains open', () => {
  1163. const definition: ConversationNodeDefinition<null> = {
  1164. kind: 'turn-boundary-probe',
  1165. match: event => event.type === 'turn/end'
  1166. ? { id: String(event.data.turn), role: 'start' }
  1167. : null,
  1168. start: () => null,
  1169. update: context => context.state,
  1170. target: 'test',
  1171. buildViewNode: context => node(context, context.start?.location.kind),
  1172. }
  1173. const assembler = new ConversationNodeAssembler(
  1174. new TestEventDefinitions([definition]),
  1175. new TestViewDefinitions([testView()]),
  1176. )
  1177. assembler.replaceWindow([
  1178. input(at(SessionSeq(1), 'turn/start', { turn: 1 })),
  1179. input(at(SessionSeq(2), 'step/start', { turn: 1, step: 1 })),
  1180. ], false)
  1181. assembler.flush()
  1182. assembler.append(input(at(SessionSeq(3), 'turn/end', { turn: 1, reason: { kind: 'aborted' } })))
  1183. assembler.flush()
  1184. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn')
  1185. })
  1186. it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => {
  1187. const definition: ConversationNodeDefinition<null> = {
  1188. kind: 'location-probe',
  1189. match: event => (event.type as string) === 'tool/ptc-dispatch-start'
  1190. ? { id: String(event.seq), role: 'start' }
  1191. : null,
  1192. start: () => null,
  1193. update: context => context.state,
  1194. target: 'test',
  1195. buildViewNode: (context) => {
  1196. const location = context.start?.location
  1197. return node(context, location?.kind === 'step'
  1198. ? `${location.turn.turn}:${location.step.step}`
  1199. : location?.kind)
  1200. },
  1201. }
  1202. const assembler = new ConversationNodeAssembler(
  1203. new TestEventDefinitions([definition]),
  1204. new TestViewDefinitions([testView()]),
  1205. )
  1206. assembler.replaceWindow([
  1207. input(at(SessionSeq(10), 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
  1208. input(at(SessionSeq(11), 'tool/ptc-dispatch-start', { rootCallId: 'root', subCallId: 'a' })),
  1209. ], true)
  1210. assembler.flush()
  1211. assembler.append(input(at(SessionSeq(12), 'tool/ptc-dispatch-start', { rootCallId: 'root', subCallId: 'b' })))
  1212. assembler.flush()
  1213. expect([...testSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
  1214. .toEqual(['2:3', '2:3'])
  1215. })
  1216. it('treats loaded end boundaries as closed when their starts precede the window', () => {
  1217. const definition: ConversationNodeDefinition<null> = {
  1218. kind: 'location-probe',
  1219. match: event => event.type === 'tool/call'
  1220. ? { id: String(event.data.callId), role: 'start' }
  1221. : null,
  1222. start: () => null,
  1223. update: context => context.state,
  1224. target: 'test',
  1225. buildViewNode: (context) => {
  1226. const location = context.start?.location
  1227. return node(context, location?.kind === 'step'
  1228. ? `${location.turn.status}:${location.step.status}`
  1229. : location?.kind)
  1230. },
  1231. }
  1232. const assembler = new ConversationNodeAssembler(
  1233. new TestEventDefinitions([definition]),
  1234. new TestViewDefinitions([testView()]),
  1235. )
  1236. assembler.replaceWindow([
  1237. input(at(SessionSeq(10), 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
  1238. input(at(SessionSeq(11), 'step/end', { turn: 2, step: 3 })),
  1239. input(at(SessionSeq(12), 'turn/end', { turn: 2, reason: { kind: 'completed' } })),
  1240. ], true)
  1241. assembler.flush()
  1242. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  1243. .toBe('closed:closed')
  1244. })
  1245. it('restarts State creation from undefined when Location changes replay a Context', () => {
  1246. const seen = vi.fn((context: Parameters<ConversationNodeDefinition<number>['start']>[0]) => {
  1247. expect(context.state).toBeUndefined()
  1248. return 1
  1249. })
  1250. const definition: ConversationNodeDefinition<number> = {
  1251. kind: 'replay-probe',
  1252. match: event => event.type === 'step/start'
  1253. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  1254. : null,
  1255. start: seen,
  1256. update: context => context.state,
  1257. target: 'test',
  1258. buildViewNode: context => node(context, context.state),
  1259. }
  1260. const assembler = new ConversationNodeAssembler(
  1261. new TestEventDefinitions([definition]),
  1262. new TestViewDefinitions([testView()]),
  1263. )
  1264. assembler.replaceWindow([input(at(SessionSeq(1), 'step/start', { turn: 1, step: 1 }))], false)
  1265. assembler.flush()
  1266. assembler.append(input(at(SessionSeq(2), 'step/end', { turn: 1, step: 1 })))
  1267. assembler.flush()
  1268. expect(seen).toHaveBeenCalledTimes(2)
  1269. })
  1270. it('invokes the fallback when only a State-only Definition claims an event', () => {
  1271. const fallbackStart = vi.fn(() => 'fallback')
  1272. const claimed: ConversationNodeDefinition<null> = {
  1273. kind: 'claimed-state',
  1274. match: event => (event.type as string) === 'command/run'
  1275. ? { id: 'claimed', role: 'start' }
  1276. : null,
  1277. start: () => null,
  1278. update: context => context.state,
  1279. }
  1280. const assembler = new ConversationNodeAssembler(
  1281. new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
  1282. new TestViewDefinitions([testView()]),
  1283. )
  1284. assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false)
  1285. assembler.flush()
  1286. expect(fallbackStart).toHaveBeenCalledOnce()
  1287. expect(testSnapshot(assembler)?.order).toHaveLength(1)
  1288. })
  1289. it('invokes the fallback when only another target claims an event', () => {
  1290. const fallbackStart = vi.fn(() => 'fallback')
  1291. const claimed: ConversationNodeDefinition<null> = {
  1292. kind: 'claimed-trajectory',
  1293. target: 'trajectory',
  1294. match: event => (event.type as string) === 'command/run'
  1295. ? { id: 'claimed', role: 'start' }
  1296. : null,
  1297. start: () => null,
  1298. update: context => context.state,
  1299. buildViewNode: () => null,
  1300. }
  1301. const assembler = new ConversationNodeAssembler(
  1302. new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
  1303. new TestViewDefinitions([testView()]),
  1304. )
  1305. assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false)
  1306. assembler.flush()
  1307. expect(fallbackStart).toHaveBeenCalledOnce()
  1308. expect(testSnapshot(assembler)?.order).toHaveLength(1)
  1309. })
  1310. it('suppresses the fallback when the same target claims an event', () => {
  1311. const fallbackStart = vi.fn(() => 'fallback')
  1312. const claimed: ConversationNodeDefinition<null> = {
  1313. kind: 'claimed',
  1314. target: 'test',
  1315. match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
  1316. start: () => null,
  1317. update: context => context.state,
  1318. buildViewNode: () => null,
  1319. }
  1320. const assembler = new ConversationNodeAssembler(
  1321. new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
  1322. new TestViewDefinitions([testView()]),
  1323. )
  1324. assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false)
  1325. assembler.flush()
  1326. expect(fallbackStart).not.toHaveBeenCalled()
  1327. expect(testSnapshot(assembler)?.order).toEqual([])
  1328. })
  1329. it('rejects withdrawing a previously materialized Node during an incremental update', () => {
  1330. const definition: ConversationNodeDefinition<boolean> = {
  1331. kind: 'toggle',
  1332. match: (event) => {
  1333. if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
  1334. if ((event.type as string) === 'toggle/hide') return { id: 'one', role: 'update' }
  1335. return null
  1336. },
  1337. start: () => true,
  1338. update: () => false,
  1339. target: 'test',
  1340. buildViewNode: context => context.state === true ? node(context, true) : null,
  1341. }
  1342. const assembler = new ConversationNodeAssembler(
  1343. new TestEventDefinitions([definition]),
  1344. new TestViewDefinitions([testView()]),
  1345. )
  1346. assembler.replaceWindow([input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' }))], false)
  1347. assembler.flush()
  1348. expect(testSnapshot(assembler)?.order).toHaveLength(1)
  1349. assembler.append(input(at(SessionSeq(2), 'toggle/hide', {})))
  1350. expect(() => assembler.flush()).toThrow(/withdrew materialized target "test"/)
  1351. expect(testSnapshot(assembler)?.order).toHaveLength(1)
  1352. })
  1353. it('fails loud when a Definition returns undefined State', () => {
  1354. const startUndefined: ConversationNodeDefinition = {
  1355. kind: 'undefined-start',
  1356. match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
  1357. start: () => undefined,
  1358. update: context => context.state,
  1359. target: 'test',
  1360. buildViewNode: () => null,
  1361. }
  1362. const startAssembler = new ConversationNodeAssembler(
  1363. new TestEventDefinitions([startUndefined]),
  1364. new TestViewDefinitions([testView()]),
  1365. )
  1366. expect(() => startAssembler.replaceWindow([
  1367. input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' })),
  1368. ], false)).toThrow(/Definition "undefined-start" returned undefined from start/)
  1369. const updateUndefined: ConversationNodeDefinition<boolean> = {
  1370. kind: 'undefined-update',
  1371. match: (event) => {
  1372. if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
  1373. if ((event.type as string) === 'command/done') return { id: 'one', role: 'update' }
  1374. return null
  1375. },
  1376. start: () => true,
  1377. update: () => undefined as never,
  1378. target: 'test',
  1379. buildViewNode: context => node(context, context.state),
  1380. }
  1381. const updateAssembler = new ConversationNodeAssembler(
  1382. new TestEventDefinitions([updateUndefined]),
  1383. new TestViewDefinitions([testView()]),
  1384. )
  1385. updateAssembler.replaceWindow([
  1386. input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' })),
  1387. ], false)
  1388. expect(() => updateAssembler.append(
  1389. input(at(SessionSeq(2), 'command/done', { commandId: 'one', kind: 'success' })),
  1390. )).toThrow(/Definition "undefined-update" returned undefined from update/)
  1391. })
  1392. it('rejects a duplicate start before mutating the existing Context', () => {
  1393. const definition: ConversationNodeDefinition<number> = {
  1394. kind: 'single-start',
  1395. match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
  1396. start: (_context, match) => match.event.seq,
  1397. update: context => context.state,
  1398. target: 'test',
  1399. buildViewNode: context => node(context, context.state),
  1400. }
  1401. const assembler = new ConversationNodeAssembler(
  1402. new TestEventDefinitions([definition]),
  1403. new TestViewDefinitions([testView()]),
  1404. )
  1405. assembler.replaceWindow([
  1406. input(at(SessionSeq(1), 'command/run', { commandId: 'one', name: 'x' })),
  1407. ], false)
  1408. assembler.flush()
  1409. expect(() => assembler.append(
  1410. input(at(SessionSeq(2), 'command/run', { commandId: 'two', name: 'x' })),
  1411. )).toThrow(/received more than one start Match/)
  1412. assembler.flush()
  1413. expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
  1414. })
  1415. })