conversation-assembler.client.spec.ts 46 KB

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