conversation-assembler.spec.ts 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047
  1. import { describe, expect, it, vi } from 'vitest'
  2. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  3. import { ConversationNodeAssembler } from '../src/client/sessions/conversation-assembler.ts'
  4. import type {
  5. ConversationEventInput, ConversationMatch, ConversationNodeContext,
  6. ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
  7. } from '../src/client/contract/conversation.ts'
  8. interface ScopeProbeStepData {
  9. readonly value: number
  10. }
  11. interface ScopeProbeTurnData {
  12. readonly valueSeenFromStep: number
  13. }
  14. declare module '../src/client/contract/conversation.ts' {
  15. interface ConversationStepDataMap {
  16. 'scope-probe': ScopeProbeStepData
  17. }
  18. interface ConversationTurnDataMap {
  19. 'scope-probe': ScopeProbeTurnData
  20. }
  21. }
  22. interface TestSnapshot {
  23. readonly order: readonly string[]
  24. readonly nodes: ReadonlyMap<string, ConversationViewNode>
  25. }
  26. class TestEventDefinitions {
  27. readonly definitions: readonly ConversationNodeDefinition[]
  28. readonly fallback: ConversationNodeDefinition | undefined
  29. constructor(
  30. definitions: readonly ConversationNodeDefinition[],
  31. fallback?: ConversationNodeDefinition,
  32. ) {
  33. this.definitions = definitions
  34. this.fallback = fallback
  35. }
  36. entries(): readonly ConversationNodeDefinition[] {
  37. return this.definitions
  38. }
  39. fallbackEntry(): ConversationNodeDefinition | undefined {
  40. return this.fallback
  41. }
  42. }
  43. class TestViewDefinitions {
  44. constructor(readonly definitions: readonly ConversationViewDefinition[]) {}
  45. entries(): readonly ConversationViewDefinition[] {
  46. return this.definitions
  47. }
  48. }
  49. function testView(
  50. apply = vi.fn(),
  51. ): ConversationViewDefinition<ConversationViewNode, TestSnapshot> {
  52. return {
  53. target: 'chat',
  54. create: () => {
  55. let current: TestSnapshot = { order: [], nodes: new Map() }
  56. return {
  57. empty: current,
  58. replace: ({ nodes }) => {
  59. current = { order: nodes.map(node => node.key), nodes: new Map(nodes.map(node => [node.key, node])) }
  60. return current
  61. },
  62. apply: ({ upserts }) => {
  63. apply(upserts)
  64. const nodes = new Map(current.nodes)
  65. const order = [...current.order]
  66. for (const node of upserts) {
  67. if (!nodes.has(node.key)) order.push(node.key)
  68. nodes.set(node.key, node)
  69. }
  70. current = { order, nodes }
  71. return current
  72. },
  73. }
  74. },
  75. }
  76. }
  77. function at(seq: number, type: string, data: unknown): SessionEvent {
  78. return { seq, time: 1_700_000_000_000 + seq, type, data } as SessionEvent
  79. }
  80. function input(event: SessionEvent): ConversationEventInput {
  81. return { event, view: undefined }
  82. }
  83. function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | undefined {
  84. return assembler.snapshot('chat') as TestSnapshot | undefined
  85. }
  86. function node(
  87. context: Parameters<NonNullable<ConversationNodeDefinition['buildViewNode']>>[0],
  88. data: unknown,
  89. ): ConversationViewNode {
  90. return {
  91. key: context.key,
  92. kind: context.kind,
  93. id: context.id,
  94. target: 'chat',
  95. data,
  96. }
  97. }
  98. function fallbackDefinition(start: () => string): ConversationNodeDefinition<string> {
  99. return {
  100. kind: 'fallback',
  101. target: 'chat',
  102. match: event => ({ id: String(event.seq), role: 'start' }),
  103. start,
  104. update: context => context.state,
  105. buildViewNode: context => node(context, context.state),
  106. }
  107. }
  108. describe('ConversationNodeAssembler', () => {
  109. it('appends through an exact business-id Context without replaying unrelated Contexts', () => {
  110. const starts = vi.fn((
  111. _context: ConversationNodeContext<{ callSeq: number; results: number }>,
  112. match: ConversationMatch,
  113. ) => ({ callSeq: match.event.seq, results: 0 }))
  114. const updates = vi.fn((context: { state: { callSeq: number; results: number } }) => ({
  115. ...context.state,
  116. results: context.state.results + 1,
  117. }))
  118. const definition: ConversationNodeDefinition<{ callSeq: number; results: number }> = {
  119. kind: 'tool',
  120. match: (event) => {
  121. if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
  122. if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
  123. return null
  124. },
  125. start: starts,
  126. update: updates,
  127. target: 'chat',
  128. buildViewNode: context => node(context, context.state),
  129. }
  130. const assembler = new ConversationNodeAssembler(
  131. new TestEventDefinitions([definition]),
  132. new TestViewDefinitions([testView()]),
  133. )
  134. assembler.replaceWindow([
  135. input(at(1, 'tool/call', { turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}' })),
  136. input(at(2, 'tool/call', { turn: 1, step: 1, callId: 'b', name: 'x', arguments: '{}' })),
  137. ], false)
  138. assembler.flush()
  139. starts.mockClear()
  140. assembler.append(input(at(3, 'tool/result', {
  141. turn: 1,
  142. step: 1,
  143. message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
  144. })))
  145. assembler.flush()
  146. expect(starts).not.toHaveBeenCalled()
  147. expect(updates).toHaveBeenCalledOnce()
  148. const snapshot = chatSnapshot(assembler)
  149. expect([...snapshot?.nodes.values() ?? []].map(value => value.data)).toEqual([
  150. { callSeq: 1, results: 1 },
  151. { callSeq: 2, results: 0 },
  152. ])
  153. })
  154. it('keeps one Match collection while a long Context appends without replay', () => {
  155. const starts = vi.fn(() => 0)
  156. const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
  157. context.state + 1
  158. ))
  159. const matchCollections = new Set<readonly ConversationMatch[]>()
  160. const definition: ConversationNodeDefinition<number> = {
  161. kind: 'append-linear',
  162. match: (event) => {
  163. const type: string = event.type
  164. if (type === 'linear/start') return { id: 'one', role: 'start' }
  165. if (type === 'linear/update') return { id: 'one', role: 'update' }
  166. return null
  167. },
  168. start: (context) => {
  169. matchCollections.add(context.matches)
  170. return starts()
  171. },
  172. update: (context) => {
  173. matchCollections.add(context.matches)
  174. return updates(context)
  175. },
  176. target: 'chat',
  177. buildViewNode: context => node(context, context.state),
  178. }
  179. const assembler = new ConversationNodeAssembler(
  180. new TestEventDefinitions([definition]),
  181. new TestViewDefinitions([testView()]),
  182. )
  183. assembler.replaceWindow([input(at(1, 'linear/start', {}))], false)
  184. starts.mockClear()
  185. for (let seq = 2; seq <= 1_001; seq++) {
  186. assembler.append(input(at(seq, 'linear/update', {})))
  187. }
  188. assembler.flush()
  189. expect(starts).not.toHaveBeenCalled()
  190. expect(updates).toHaveBeenCalledTimes(1_000)
  191. expect(matchCollections.size).toBe(1)
  192. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1_000)
  193. })
  194. it('merges an older page and replays its affected Context once', () => {
  195. const starts = vi.fn(() => 0)
  196. const updates = vi.fn((context: ConversationNodeContext<number> & { readonly state: number }) => (
  197. context.state + 1
  198. ))
  199. const definition: ConversationNodeDefinition<number> = {
  200. kind: 'prepend-linear',
  201. match: (event) => {
  202. const type: string = event.type
  203. if (type === 'linear/start') return { id: 'one', role: 'start' }
  204. if (type === 'linear/update') return { id: 'one', role: 'update' }
  205. return null
  206. },
  207. start: starts,
  208. update: updates,
  209. target: 'chat',
  210. buildViewNode: context => node(context, context.state),
  211. }
  212. const assembler = new ConversationNodeAssembler(
  213. new TestEventDefinitions([definition]),
  214. new TestViewDefinitions([testView()]),
  215. )
  216. const current = Array.from({ length: 100 }, (_, index) => (
  217. input(at(index + 102, 'linear/update', {}))
  218. ))
  219. assembler.replaceWindow(current, true)
  220. assembler.flush()
  221. expect(starts).not.toHaveBeenCalled()
  222. expect(updates).not.toHaveBeenCalled()
  223. const older = [
  224. input(at(1, 'linear/start', {})),
  225. ...Array.from({ length: 100 }, (_, index) => (
  226. input(at(index + 2, 'linear/update', {}))
  227. )),
  228. ]
  229. assembler.prepend(older, false)
  230. assembler.flush()
  231. expect(starts).toHaveBeenCalledOnce()
  232. expect(updates).toHaveBeenCalledTimes(200)
  233. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(200)
  234. })
  235. it('collects an update before its start and replays it once prepend supplies the start', () => {
  236. const updates = vi.fn((context: { state: { settled: boolean } }) => ({ ...context.state, settled: true }))
  237. const definition: ConversationNodeDefinition<{ settled: boolean }> = {
  238. kind: 'tool',
  239. match: (event) => {
  240. if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
  241. if (event.type === 'tool/result') return { id: String(event.data.message.source.callId), role: 'update' }
  242. return null
  243. },
  244. start: () => ({ settled: false }),
  245. update: updates,
  246. target: 'chat',
  247. buildViewNode: context => node(context, context.state ?? { pendingStart: true }),
  248. }
  249. const assembler = new ConversationNodeAssembler(
  250. new TestEventDefinitions([definition]),
  251. new TestViewDefinitions([testView()]),
  252. )
  253. assembler.replaceWindow([input(at(10, 'tool/result', {
  254. turn: 1,
  255. step: 1,
  256. message: { source: { type: 'tool-result', callId: 'a' }, content: [], isError: false },
  257. }))], true)
  258. assembler.flush()
  259. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  260. .toEqual({ pendingStart: true })
  261. assembler.prepend([input(at(5, 'tool/call', {
  262. turn: 1, step: 1, callId: 'a', name: 'x', arguments: '{}',
  263. }))], false)
  264. assembler.flush()
  265. expect(updates).toHaveBeenCalledOnce()
  266. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  267. .toEqual({ settled: true })
  268. })
  269. it('rejects a Definition whose declared start follows an update in log order', () => {
  270. const definition: ConversationNodeDefinition<null> = {
  271. kind: 'invalid-lifecycle',
  272. match: event => event.type === 'turn/end'
  273. ? { id: 'one', role: 'start' }
  274. : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null,
  275. start: () => null,
  276. update: context => context.state,
  277. target: 'chat',
  278. buildViewNode: () => null,
  279. }
  280. const assembler = new ConversationNodeAssembler(
  281. new TestEventDefinitions([definition]),
  282. new TestViewDefinitions([testView()]),
  283. )
  284. expect(() => assembler.replaceWindow([
  285. input(at(1, 'turn/start', { turn: 1 })),
  286. input(at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } })),
  287. ], false)).toThrow('received an update before its start Match')
  288. })
  289. it('replays a window-gap reader when prepend supplies a nearer predecessor', () => {
  290. const source: ConversationNodeDefinition<number> = {
  291. kind: 'source',
  292. match: event => event.type === 'user/message'
  293. ? { id: String(event.data.id), role: 'start' }
  294. : null,
  295. start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0),
  296. update: context => context.state,
  297. target: 'chat',
  298. buildViewNode: () => null,
  299. }
  300. const consumerStart = vi.fn((
  301. _context: Parameters<ConversationNodeDefinition<number>['start']>[0],
  302. _match: Parameters<ConversationNodeDefinition<number>['start']>[1],
  303. reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
  304. ) => reader.previous<number>('source')?.state ?? -1)
  305. const consumer: ConversationNodeDefinition<number> = {
  306. kind: 'consumer',
  307. match: event => event.type === 'assistant/message'
  308. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  309. : null,
  310. start: consumerStart,
  311. update: context => context.state,
  312. target: 'chat',
  313. buildViewNode: context => node(context, context.state),
  314. }
  315. const assembler = new ConversationNodeAssembler(
  316. new TestEventDefinitions([source, consumer]),
  317. new TestViewDefinitions([testView()]),
  318. )
  319. assembler.replaceWindow([input(at(10, 'assistant/message', {
  320. turn: 2, step: 1, message: { role: 'assistant', content: [] },
  321. }))], true)
  322. assembler.flush()
  323. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
  324. assembler.prepend([input(at(5, 'user/message', {
  325. id: 'm1', value: 7, content: [], source: { kind: 'user' },
  326. }))], false)
  327. assembler.flush()
  328. expect(consumerStart).toHaveBeenCalledTimes(2)
  329. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(7)
  330. })
  331. it('keeps the predecessor index ordered across prepend and append', () => {
  332. const source: ConversationNodeDefinition<number> = {
  333. kind: 'source',
  334. match: event => event.type === 'user/message'
  335. ? { id: String(event.data.id), role: 'start' }
  336. : null,
  337. start: (_context, match) => match.event.seq,
  338. update: context => context.state,
  339. target: 'chat',
  340. buildViewNode: () => null,
  341. }
  342. const consumer: ConversationNodeDefinition<number> = {
  343. kind: 'consumer',
  344. match: event => event.type === 'assistant/message'
  345. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  346. : null,
  347. start: (_context, _match, reader) => reader.previous<number>('source')?.state ?? -1,
  348. update: context => context.state,
  349. target: 'chat',
  350. buildViewNode: context => node(context, context.state),
  351. }
  352. const assembler = new ConversationNodeAssembler(
  353. new TestEventDefinitions([source, consumer]),
  354. new TestViewDefinitions([testView()]),
  355. )
  356. assembler.replaceWindow([
  357. input(at(40, 'user/message', { id: 'm40', content: [], source: { kind: 'user' } })),
  358. input(at(50, 'assistant/message', {
  359. turn: 1, step: 1, message: { role: 'assistant', content: [] },
  360. })),
  361. ], true)
  362. assembler.flush()
  363. assembler.prepend([
  364. input(at(10, 'user/message', { id: 'm10', content: [], source: { kind: 'user' } })),
  365. input(at(30, 'user/message', { id: 'm30', content: [], source: { kind: 'user' } })),
  366. ], false)
  367. assembler.flush()
  368. assembler.append(input(at(60, 'user/message', {
  369. id: 'm60', content: [], source: { kind: 'user' },
  370. })))
  371. assembler.append(input(at(70, 'assistant/message', {
  372. turn: 2, step: 1, message: { role: 'assistant', content: [] },
  373. })))
  374. assembler.flush()
  375. expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
  376. .toEqual([40, 60])
  377. })
  378. it('replays a window-gap reader when an empty prepend closes the unknown prefix', () => {
  379. const consumerStart = vi.fn((
  380. _context: Parameters<ConversationNodeDefinition<number>['start']>[0],
  381. _match: Parameters<ConversationNodeDefinition<number>['start']>[1],
  382. reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
  383. ) => reader.previous<number>('source')?.state ?? -1)
  384. const consumer: ConversationNodeDefinition<number> = {
  385. kind: 'consumer',
  386. match: event => event.type === 'assistant/message'
  387. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  388. : null,
  389. start: consumerStart,
  390. update: context => context.state,
  391. target: 'chat',
  392. buildViewNode: context => node(context, context.state),
  393. }
  394. const assembler = new ConversationNodeAssembler(
  395. new TestEventDefinitions([consumer]),
  396. new TestViewDefinitions([testView()]),
  397. )
  398. assembler.replaceWindow([input(at(10, 'assistant/message', {
  399. turn: 2, step: 1, message: { role: 'assistant', content: [] },
  400. }))], true)
  401. assembler.flush()
  402. expect(assembler.prepend([], false)).toBe('immediate')
  403. assembler.flush()
  404. expect(consumerStart).toHaveBeenCalledTimes(2)
  405. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1)
  406. })
  407. it('replays direct dependents when an append revises their predecessor Context', () => {
  408. const source: ConversationNodeDefinition<number> = {
  409. kind: 'source',
  410. match: (event) => {
  411. if (event.type === 'user/message') return { id: 'one', role: 'start' }
  412. if ((event.type as string) === 'source/update') return { id: 'one', role: 'update' }
  413. return null
  414. },
  415. start: () => 1,
  416. update: (_context, match) => (match.event.data as unknown as { value: number }).value,
  417. target: 'chat',
  418. buildViewNode: () => null,
  419. }
  420. const consumerStart = vi.fn((
  421. _context: Parameters<ConversationNodeDefinition<number>['start']>[0],
  422. _match: Parameters<ConversationNodeDefinition<number>['start']>[1],
  423. reader: Parameters<ConversationNodeDefinition<number>['start']>[2],
  424. ) => reader.previous<number>('source')?.state ?? -1)
  425. const consumer: ConversationNodeDefinition<number> = {
  426. kind: 'consumer',
  427. match: event => event.type === 'assistant/message'
  428. ? { id: 'one', role: 'start' }
  429. : null,
  430. start: consumerStart,
  431. update: context => context.state,
  432. target: 'chat',
  433. buildViewNode: context => node(context, context.state),
  434. }
  435. const assembler = new ConversationNodeAssembler(
  436. new TestEventDefinitions([source, consumer]),
  437. new TestViewDefinitions([testView()]),
  438. )
  439. assembler.replaceWindow([
  440. input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
  441. input(at(2, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
  442. ], false)
  443. assembler.flush()
  444. expect(assembler.append(input(at(3, 'source/update', { value: 2 })))).toBe('immediate')
  445. assembler.flush()
  446. expect(consumerStart).toHaveBeenCalledTimes(2)
  447. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(2)
  448. })
  449. it('replays a transitive dependency closure in start order', () => {
  450. const sourceA: ConversationNodeDefinition<number> = {
  451. kind: 'diamond-a',
  452. match: (event) => {
  453. if (event.type === 'user/message') return { id: 'one', role: 'start' }
  454. if ((event.type as string) === 'diamond/a') return { id: 'one', role: 'update' }
  455. return null
  456. },
  457. start: () => 1,
  458. update: (_context, match) => (match.event.data as unknown as { value: number }).value,
  459. target: 'chat',
  460. buildViewNode: () => null,
  461. }
  462. const sourceX: ConversationNodeDefinition<number> = {
  463. kind: 'diamond-x',
  464. match: (event) => {
  465. if (event.type === 'turn/start') return { id: 'one', role: 'start' }
  466. if ((event.type as string) === 'diamond/x') return { id: 'one', role: 'update' }
  467. return null
  468. },
  469. start: () => 10,
  470. update: (_context, match) => (match.event.data as unknown as { value: number }).value,
  471. target: 'chat',
  472. buildViewNode: () => null,
  473. }
  474. const middle: ConversationNodeDefinition<number> = {
  475. kind: 'diamond-b',
  476. match: event => event.type === 'assistant/message'
  477. ? { id: 'one', role: 'start' }
  478. : null,
  479. start: (_context, _match, reader) => (
  480. (reader.previous<number>('diamond-a')?.state ?? 0)
  481. + (reader.previous<number>('diamond-x')?.state ?? 0)
  482. ),
  483. update: context => context.state,
  484. target: 'chat',
  485. buildViewNode: context => node(context, context.state),
  486. }
  487. const consumer: ConversationNodeDefinition<number> = {
  488. kind: 'diamond-c',
  489. match: event => event.type === 'tool/call'
  490. ? { id: 'one', role: 'start' }
  491. : null,
  492. start: (_context, _match, reader) => (
  493. (reader.previous<number>('diamond-a')?.state ?? 0) * 100
  494. + (reader.previous<number>('diamond-b')?.state ?? 0)
  495. ),
  496. update: context => context.state,
  497. target: 'chat',
  498. buildViewNode: context => node(context, context.state),
  499. }
  500. const assembler = new ConversationNodeAssembler(
  501. new TestEventDefinitions([sourceA, sourceX, middle, consumer]),
  502. new TestViewDefinitions([testView()]),
  503. )
  504. assembler.replaceWindow([
  505. input(at(1, 'user/message', { id: 'source', content: [], source: { kind: 'user' } })),
  506. input(at(2, 'turn/start', { turn: 1 })),
  507. input(at(3, 'assistant/message', { turn: 1, step: 1, message: { role: 'assistant', content: [] } })),
  508. input(at(4, 'tool/call', { turn: 1, step: 1, callId: 'call', name: 'x', arguments: '{}' })),
  509. ], false)
  510. assembler.append(input(at(5, 'diamond/x', { value: 20 })))
  511. assembler.append(input(at(6, 'diamond/a', { value: 2 })))
  512. assembler.flush()
  513. const value = [...chatSnapshot(assembler)?.nodes.values() ?? []]
  514. .find(candidate => candidate.kind === 'diamond-c')
  515. expect(value?.data).toBe(222)
  516. })
  517. it('replays Location-derived State and rebuilds only owned Nodes when a step closes', () => {
  518. const apply = vi.fn()
  519. const starts = vi.fn((
  520. _context: Parameters<ConversationNodeDefinition<string>['start']>[0],
  521. match: Parameters<ConversationNodeDefinition<string>['start']>[1],
  522. ) => match.location.kind === 'step' ? match.location.step.status : 'missing')
  523. const definition: ConversationNodeDefinition<string> = {
  524. kind: 'step',
  525. match: event => event.type === 'step/start'
  526. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  527. : null,
  528. start: starts,
  529. update: context => context.state,
  530. target: 'chat',
  531. buildViewNode: context => node(context, context.state),
  532. }
  533. const assembler = new ConversationNodeAssembler(
  534. new TestEventDefinitions([definition]),
  535. new TestViewDefinitions([testView(apply)]),
  536. )
  537. assembler.replaceWindow([
  538. input(at(1, 'turn/start', { turn: 1 })),
  539. input(at(2, 'step/start', { turn: 1, step: 1 })),
  540. ], false)
  541. assembler.flush()
  542. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('open')
  543. assembler.append(input(at(3, 'step/end', { turn: 1, step: 1 })))
  544. assembler.flush()
  545. expect(starts).toHaveBeenCalledTimes(2)
  546. expect(apply).toHaveBeenCalledOnce()
  547. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('closed')
  548. })
  549. it('lets one Context publish Step and Turn data in phase order', () => {
  550. interface State {
  551. readonly turn: number
  552. readonly step: number
  553. readonly value: number
  554. }
  555. const definition: ConversationNodeDefinition<State> = {
  556. kind: 'scope-probe',
  557. match: (event) => {
  558. if (event.type === 'step/start') {
  559. return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  560. }
  561. if ((event.type as string) === 'scope-probe/update') {
  562. return { id: '1:1', role: 'update' }
  563. }
  564. return null
  565. },
  566. start: (_context, match) => {
  567. if (match.event.type !== 'step/start') throw new Error('scope probe requires step/start')
  568. return { turn: match.event.data.turn, step: match.event.data.step, value: 1 }
  569. },
  570. update: (_context, match) => ({
  571. turn: 1,
  572. step: 1,
  573. value: (match.event.data as unknown as { value: number }).value,
  574. }),
  575. buildLocationData: (context, scope) => {
  576. const state = context.state
  577. if (state === undefined) return null
  578. if (scope === 'step') {
  579. return {
  580. kind: 'step',
  581. turn: state.turn,
  582. step: state.step,
  583. key: 'scope-probe',
  584. value: { value: state.value },
  585. }
  586. }
  587. const location = context.start?.location
  588. const stepValue = location?.kind === 'step'
  589. ? location.step.data.get('scope-probe')?.value
  590. : undefined
  591. return {
  592. kind: 'turn',
  593. turn: state.turn,
  594. key: 'scope-probe',
  595. value: { valueSeenFromStep: stepValue ?? -1 },
  596. }
  597. },
  598. target: 'chat',
  599. buildViewNode: (context) => {
  600. const location = context.start?.location
  601. if (location?.kind !== 'step') return null
  602. return node(context, {
  603. step: location.step.data.get('scope-probe')?.value,
  604. turn: location.turn.data.get('scope-probe')?.valueSeenFromStep,
  605. })
  606. },
  607. }
  608. const assembler = new ConversationNodeAssembler(
  609. new TestEventDefinitions([definition]),
  610. new TestViewDefinitions([testView()]),
  611. )
  612. assembler.replaceWindow([
  613. input(at(1, 'turn/start', { turn: 1 })),
  614. input(at(2, 'step/start', { turn: 1, step: 1 })),
  615. ], false)
  616. assembler.flush()
  617. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  618. .toEqual({ step: 1, turn: 1 })
  619. assembler.append(input(at(3, 'scope-probe/update', { turn: 1, step: 1, value: 2 })))
  620. assembler.flush()
  621. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  622. .toEqual({ step: 2, turn: 2 })
  623. })
  624. it('updates existing turn Locations when their Step membership changes', () => {
  625. const apply = vi.fn()
  626. const definition: ConversationNodeDefinition<null> = {
  627. kind: 'turn-probe',
  628. match: event => event.type === 'turn/start'
  629. ? { id: String(event.data.turn), role: 'start' }
  630. : null,
  631. start: () => null,
  632. update: context => context.state,
  633. target: 'chat',
  634. buildViewNode: context => node(context, context.start?.location.kind === 'turn'
  635. ? context.start.location.turn.steps.length
  636. : -1),
  637. }
  638. const assembler = new ConversationNodeAssembler(
  639. new TestEventDefinitions([definition]),
  640. new TestViewDefinitions([testView(apply)]),
  641. )
  642. assembler.replaceWindow([input(at(1, 'turn/start', { turn: 1 }))], false)
  643. assembler.flush()
  644. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(0)
  645. assembler.append(input(at(2, 'step/start', { turn: 1, step: 1 })))
  646. assembler.flush()
  647. expect(apply).toHaveBeenCalledOnce()
  648. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
  649. })
  650. it('publishes a changed timeline even when no business Definition claims the boundary', () => {
  651. const apply = vi.fn()
  652. const assembler = new ConversationNodeAssembler(
  653. new TestEventDefinitions([]),
  654. new TestViewDefinitions([testView(apply)]),
  655. )
  656. assembler.replaceWindow([], false)
  657. assembler.flush()
  658. assembler.append(input(at(1, 'turn/start', { turn: 1 })))
  659. assembler.flush()
  660. expect(apply).toHaveBeenCalledOnce()
  661. expect(chatSnapshot(assembler)?.order).toEqual([])
  662. })
  663. it('clears the prior Step at a new Turn and honors explicit session ownership', () => {
  664. const definition: ConversationNodeDefinition<null> = {
  665. kind: 'location-probe',
  666. match: (event) => {
  667. if ((event.type as string) === 'command/run') {
  668. return {
  669. id: (event.data as unknown as { commandId: string }).commandId,
  670. role: 'start',
  671. }
  672. }
  673. if ((event.type as string) === 'compact/start') {
  674. return {
  675. id: (event.data as unknown as { compactionId: string }).compactionId,
  676. role: 'start',
  677. }
  678. }
  679. return null
  680. },
  681. start: () => null,
  682. update: context => context.state,
  683. target: 'chat',
  684. buildViewNode: (context) => {
  685. const location = context.start?.location
  686. const data = location?.kind === 'step'
  687. ? `step:${location.turn.turn}:${location.step.step}`
  688. : location?.kind === 'turn' ? `turn:${location.turn.turn}` : location?.kind
  689. return node(context, data)
  690. },
  691. }
  692. const assembler = new ConversationNodeAssembler(
  693. new TestEventDefinitions([definition]),
  694. new TestViewDefinitions([testView()]),
  695. )
  696. assembler.replaceWindow([
  697. input(at(1, 'turn/start', { turn: 1 })),
  698. input(at(2, 'step/start', { turn: 1, step: 1 })),
  699. input(at(3, 'turn/start', { turn: 2 })),
  700. input(at(4, 'command/run', { commandId: 'command', name: 'x' })),
  701. input(at(5, 'compact/start', { compactionId: 'compact', turn: null })),
  702. ], false)
  703. assembler.flush()
  704. expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
  705. .toEqual(['turn:2', 'session'])
  706. })
  707. it('assigns turn boundaries to the Turn even when a Step remains open', () => {
  708. const definition: ConversationNodeDefinition<null> = {
  709. kind: 'turn-boundary-probe',
  710. match: event => event.type === 'turn/end'
  711. ? { id: String(event.data.turn), role: 'start' }
  712. : null,
  713. start: () => null,
  714. update: context => context.state,
  715. target: 'chat',
  716. buildViewNode: context => node(context, context.start?.location.kind),
  717. }
  718. const assembler = new ConversationNodeAssembler(
  719. new TestEventDefinitions([definition]),
  720. new TestViewDefinitions([testView()]),
  721. )
  722. assembler.replaceWindow([
  723. input(at(1, 'turn/start', { turn: 1 })),
  724. input(at(2, 'step/start', { turn: 1, step: 1 })),
  725. ], false)
  726. assembler.flush()
  727. assembler.append(input(at(3, 'turn/end', { turn: 1, reason: { kind: 'aborted' } })))
  728. assembler.flush()
  729. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe('turn')
  730. })
  731. it('carries explicit coordinates across coordinate-free events in a partial window and live tail', () => {
  732. const definition: ConversationNodeDefinition<null> = {
  733. kind: 'location-probe',
  734. match: event => (event.type as string) === 'tool/code-dispatch-start'
  735. ? { id: String(event.seq), role: 'start' }
  736. : null,
  737. start: () => null,
  738. update: context => context.state,
  739. target: 'chat',
  740. buildViewNode: (context) => {
  741. const location = context.start?.location
  742. return node(context, location?.kind === 'step'
  743. ? `${location.turn.turn}:${location.step.step}`
  744. : location?.kind)
  745. },
  746. }
  747. const assembler = new ConversationNodeAssembler(
  748. new TestEventDefinitions([definition]),
  749. new TestViewDefinitions([testView()]),
  750. )
  751. assembler.replaceWindow([
  752. input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
  753. input(at(11, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'a' })),
  754. ], true)
  755. assembler.flush()
  756. assembler.append(input(at(12, 'tool/code-dispatch-start', { rootCallId: 'root', subCallId: 'b' })))
  757. assembler.flush()
  758. expect([...chatSnapshot(assembler)?.nodes.values() ?? []].map(value => value.data))
  759. .toEqual(['2:3', '2:3'])
  760. })
  761. it('treats loaded end boundaries as closed when their starts precede the window', () => {
  762. const definition: ConversationNodeDefinition<null> = {
  763. kind: 'location-probe',
  764. match: event => event.type === 'tool/call'
  765. ? { id: String(event.data.callId), role: 'start' }
  766. : null,
  767. start: () => null,
  768. update: context => context.state,
  769. target: 'chat',
  770. buildViewNode: (context) => {
  771. const location = context.start?.location
  772. return node(context, location?.kind === 'step'
  773. ? `${location.turn.status}:${location.step.status}`
  774. : location?.kind)
  775. },
  776. }
  777. const assembler = new ConversationNodeAssembler(
  778. new TestEventDefinitions([definition]),
  779. new TestViewDefinitions([testView()]),
  780. )
  781. assembler.replaceWindow([
  782. input(at(10, 'tool/call', { turn: 2, step: 3, callId: 'root', name: 'x', arguments: '{}' })),
  783. input(at(11, 'step/end', { turn: 2, step: 3 })),
  784. input(at(12, 'turn/end', { turn: 2, reason: { kind: 'completed' } })),
  785. ], true)
  786. assembler.flush()
  787. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data)
  788. .toBe('closed:closed')
  789. })
  790. it('restarts State creation from undefined when Location changes replay a Context', () => {
  791. const seen = vi.fn((context: Parameters<ConversationNodeDefinition<number>['start']>[0]) => {
  792. expect(context.state).toBeUndefined()
  793. return 1
  794. })
  795. const definition: ConversationNodeDefinition<number> = {
  796. kind: 'replay-probe',
  797. match: event => event.type === 'step/start'
  798. ? { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
  799. : null,
  800. start: seen,
  801. update: context => context.state,
  802. target: 'chat',
  803. buildViewNode: context => node(context, context.state),
  804. }
  805. const assembler = new ConversationNodeAssembler(
  806. new TestEventDefinitions([definition]),
  807. new TestViewDefinitions([testView()]),
  808. )
  809. assembler.replaceWindow([input(at(1, 'step/start', { turn: 1, step: 1 }))], false)
  810. assembler.flush()
  811. assembler.append(input(at(2, 'step/end', { turn: 1, step: 1 })))
  812. assembler.flush()
  813. expect(seen).toHaveBeenCalledTimes(2)
  814. })
  815. it('invokes the fallback when only a State-only Definition claims an event', () => {
  816. const fallbackStart = vi.fn(() => 'fallback')
  817. const claimed: ConversationNodeDefinition<null> = {
  818. kind: 'claimed-state',
  819. match: event => (event.type as string) === 'command/run'
  820. ? { id: 'claimed', role: 'start' }
  821. : null,
  822. start: () => null,
  823. update: context => context.state,
  824. }
  825. const assembler = new ConversationNodeAssembler(
  826. new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
  827. new TestViewDefinitions([testView()]),
  828. )
  829. assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
  830. assembler.flush()
  831. expect(fallbackStart).toHaveBeenCalledOnce()
  832. expect(chatSnapshot(assembler)?.order).toHaveLength(1)
  833. })
  834. it('invokes the fallback when only another target claims an event', () => {
  835. const fallbackStart = vi.fn(() => 'fallback')
  836. const claimed: ConversationNodeDefinition<null> = {
  837. kind: 'claimed-trajectory',
  838. target: 'trajectory',
  839. match: event => (event.type as string) === 'command/run'
  840. ? { id: 'claimed', role: 'start' }
  841. : null,
  842. start: () => null,
  843. update: context => context.state,
  844. buildViewNode: () => null,
  845. }
  846. const assembler = new ConversationNodeAssembler(
  847. new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
  848. new TestViewDefinitions([testView()]),
  849. )
  850. assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
  851. assembler.flush()
  852. expect(fallbackStart).toHaveBeenCalledOnce()
  853. expect(chatSnapshot(assembler)?.order).toHaveLength(1)
  854. })
  855. it('suppresses the fallback when the same target claims an event', () => {
  856. const fallbackStart = vi.fn(() => 'fallback')
  857. const claimed: ConversationNodeDefinition<null> = {
  858. kind: 'claimed',
  859. target: 'chat',
  860. match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null,
  861. start: () => null,
  862. update: context => context.state,
  863. buildViewNode: () => null,
  864. }
  865. const assembler = new ConversationNodeAssembler(
  866. new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)),
  867. new TestViewDefinitions([testView()]),
  868. )
  869. assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
  870. assembler.flush()
  871. expect(fallbackStart).not.toHaveBeenCalled()
  872. expect(chatSnapshot(assembler)?.order).toEqual([])
  873. })
  874. it('rejects withdrawing a previously materialized Node during an incremental update', () => {
  875. const definition: ConversationNodeDefinition<boolean> = {
  876. kind: 'toggle',
  877. match: (event) => {
  878. if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
  879. if ((event.type as string) === 'toggle/hide') return { id: 'one', role: 'update' }
  880. return null
  881. },
  882. start: () => true,
  883. update: () => false,
  884. target: 'chat',
  885. buildViewNode: context => context.state === true ? node(context, true) : null,
  886. }
  887. const assembler = new ConversationNodeAssembler(
  888. new TestEventDefinitions([definition]),
  889. new TestViewDefinitions([testView()]),
  890. )
  891. assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false)
  892. assembler.flush()
  893. expect(chatSnapshot(assembler)?.order).toHaveLength(1)
  894. assembler.append(input(at(2, 'toggle/hide', {})))
  895. expect(() => assembler.flush()).toThrow(/withdrew materialized target "chat"/)
  896. expect(chatSnapshot(assembler)?.order).toHaveLength(1)
  897. })
  898. it('fails loud when a Definition returns undefined State', () => {
  899. const startUndefined: ConversationNodeDefinition = {
  900. kind: 'undefined-start',
  901. match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
  902. start: () => undefined,
  903. update: context => context.state,
  904. target: 'chat',
  905. buildViewNode: () => null,
  906. }
  907. const startAssembler = new ConversationNodeAssembler(
  908. new TestEventDefinitions([startUndefined]),
  909. new TestViewDefinitions([testView()]),
  910. )
  911. expect(() => startAssembler.replaceWindow([
  912. input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
  913. ], false)).toThrow(/Definition "undefined-start" returned undefined from start/)
  914. const updateUndefined: ConversationNodeDefinition<boolean> = {
  915. kind: 'undefined-update',
  916. match: (event) => {
  917. if ((event.type as string) === 'command/run') return { id: 'one', role: 'start' }
  918. if ((event.type as string) === 'command/done') return { id: 'one', role: 'update' }
  919. return null
  920. },
  921. start: () => true,
  922. update: () => undefined as never,
  923. target: 'chat',
  924. buildViewNode: context => node(context, context.state),
  925. }
  926. const updateAssembler = new ConversationNodeAssembler(
  927. new TestEventDefinitions([updateUndefined]),
  928. new TestViewDefinitions([testView()]),
  929. )
  930. updateAssembler.replaceWindow([
  931. input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
  932. ], false)
  933. expect(() => updateAssembler.append(
  934. input(at(2, 'command/done', { commandId: 'one', kind: 'success' })),
  935. )).toThrow(/Definition "undefined-update" returned undefined from update/)
  936. })
  937. it('rejects a duplicate start before mutating the existing Context', () => {
  938. const definition: ConversationNodeDefinition<number> = {
  939. kind: 'single-start',
  940. match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null,
  941. start: (_context, match) => match.event.seq,
  942. update: context => context.state,
  943. target: 'chat',
  944. buildViewNode: context => node(context, context.state),
  945. }
  946. const assembler = new ConversationNodeAssembler(
  947. new TestEventDefinitions([definition]),
  948. new TestViewDefinitions([testView()]),
  949. )
  950. assembler.replaceWindow([
  951. input(at(1, 'command/run', { commandId: 'one', name: 'x' })),
  952. ], false)
  953. assembler.flush()
  954. expect(() => assembler.append(
  955. input(at(2, 'command/run', { commandId: 'two', name: 'x' })),
  956. )).toThrow(/received more than one start Match/)
  957. assembler.flush()
  958. expect([...chatSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1)
  959. })
  960. })