conversation-node-definitions.client.spec.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089
  1. import { describe, expect, it } from 'vitest'
  2. import type {
  3. ChatConversationViewNode, ChatSnapshot,
  4. } from '@deepseek-ai/dsh-client-ui-chat/client'
  5. import {
  6. ConversationNodeAssembler,
  7. type ConversationEventInput,
  8. type ConversationNodeDefinition,
  9. type ConversationViewDefinition,
  10. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  11. import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
  12. import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
  13. import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
  14. import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
  15. import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
  16. import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
  17. import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
  18. import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
  19. import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
  20. import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
  21. import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
  22. import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
  23. import type {
  24. AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
  25. } from '../src/client/contract/chat-nodes.ts'
  26. const DEFINITIONS: readonly ConversationNodeDefinition[] = [
  27. nextTurnInboxDefinition,
  28. nextStepInboxDefinition,
  29. messageDefinition,
  30. assistantDefinition,
  31. toolDefinition,
  32. commandDefinition,
  33. compactionDefinition,
  34. retryDefinition,
  35. turnErrorDefinition,
  36. turnMaxTokensDefinition,
  37. turnTailDefinition,
  38. ]
  39. class TestEventDefinitions {
  40. entries(): readonly ConversationNodeDefinition[] {
  41. return DEFINITIONS
  42. }
  43. fallbackEntry(): ConversationNodeDefinition {
  44. return unknownFallbackDefinition
  45. }
  46. }
  47. class TestViewDefinitions {
  48. entries(): readonly ConversationViewDefinition[] {
  49. return [chatViewDefinition]
  50. }
  51. }
  52. function at(
  53. seq: number,
  54. type: string,
  55. data: unknown,
  56. extra: Record<string, unknown> = {},
  57. ): ConversationEventInput {
  58. return {
  59. event: {
  60. seq,
  61. time: 1_700_000_000_000 + seq,
  62. type,
  63. data,
  64. ...extra,
  65. } as unknown as ConversationEventInput['event'],
  66. }
  67. }
  68. function assembler(entries: readonly ConversationEventInput[] = [], hasMore = false): ConversationNodeAssembler {
  69. const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
  70. value.replaceWindow(entries, hasMore)
  71. value.flush()
  72. return value
  73. }
  74. function snapshot(value: ConversationNodeAssembler): ChatSnapshot {
  75. const current = value.snapshot('chat') as ChatSnapshot | undefined
  76. if (current === undefined) throw new Error('chat view was not registered')
  77. return current
  78. }
  79. function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
  80. return value.nodes.values().find(candidate => candidate.kind === kind)
  81. }
  82. function textMessage(id: string, text: string) {
  83. return {
  84. id,
  85. role: 'user',
  86. content: [{ type: 'text', text }],
  87. source: { kind: 'user' },
  88. }
  89. }
  90. function assistantMessage(id: string, text: string) {
  91. return {
  92. id,
  93. role: 'assistant',
  94. content: [{ type: 'text', text }],
  95. source: { kind: 'model', provider: 'fake', model: 'fake' },
  96. }
  97. }
  98. function toolResult(callId: string, text: string, isError = false) {
  99. return {
  100. id: `result-${callId}`,
  101. role: 'user',
  102. source: { kind: 'tool', callId },
  103. content: [{
  104. type: 'tool-result',
  105. toolCallId: callId,
  106. content: [{ type: 'text', text }],
  107. isError,
  108. }],
  109. }
  110. }
  111. describe('built-in conversation node Definitions', () => {
  112. it('keeps ordinary command-only history inactive for the Conversation shell', () => {
  113. const value = assembler([
  114. at(1, 'command/run', {
  115. commandId: 'command-1',
  116. name: 'help',
  117. source: { kind: 'user' },
  118. }),
  119. at(2, 'command/done', {
  120. commandId: 'command-1',
  121. kind: 'success',
  122. }),
  123. ])
  124. const current = snapshot(value)
  125. expect(current.order).toHaveLength(1)
  126. expect(current.nodes.get(current.order[0] ?? '')?.kind).toBe('command')
  127. expect(chatViewDefinition.isActive?.(current)).toBe(false)
  128. })
  129. it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
  130. const value = assembler([
  131. at(1, 'turn/start', { turn: 1 }),
  132. at(2, 'step/start', { turn: 1, step: 1 }),
  133. at(3, 'assistant/chunk', {
  134. turn: 1,
  135. step: 1,
  136. chunk: { type: 'text-delta', index: 0, text: 'streaming' },
  137. }),
  138. ])
  139. const runningSnapshot = snapshot(value)
  140. const running = node(runningSnapshot, 'assistant-step')
  141. expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] })
  142. const order = runningSnapshot.order
  143. value.append(at(4, 'assistant/message', {
  144. turn: 1,
  145. step: 1,
  146. message: assistantMessage('assistant-1', 'settled'),
  147. }, { surfaceOp: 'append' }))
  148. value.flush()
  149. const settledSnapshot = snapshot(value)
  150. const settled = node(settledSnapshot, 'assistant-step')
  151. expect(settled?.key).toBe(running?.key)
  152. expect(settledSnapshot.order).toBe(order)
  153. expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] })
  154. const interruptedValue = assembler([
  155. at(10, 'turn/start', { turn: 2 }),
  156. at(11, 'step/start', { turn: 2, step: 1 }),
  157. at(12, 'assistant/chunk', {
  158. turn: 2,
  159. step: 1,
  160. chunk: { type: 'text-delta', index: 0, text: 'partial' },
  161. }),
  162. at(13, 'step/end', { turn: 2, step: 1 }),
  163. ])
  164. const interrupted = node(snapshot(interruptedValue), 'assistant-step')
  165. expect(interrupted?.data).toMatchObject({ status: 'interrupted' })
  166. expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
  167. const markedValue = assembler([
  168. at(20, 'turn/start', { turn: 3 }),
  169. at(21, 'step/start', { turn: 3, step: 1 }),
  170. at(22, 'assistant/message', {
  171. turn: 3,
  172. step: 1,
  173. message: assistantMessage('assistant-3', 'cut short'),
  174. interrupted: true,
  175. }, { surfaceOp: 'append' }),
  176. ])
  177. const marked = node(snapshot(markedValue), 'assistant-step')
  178. expect(marked?.data).toMatchObject({ status: 'interrupted', blocks: [{ kind: 'text', text: 'cut short' }] })
  179. expect((marked?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
  180. const hiddenValue = assembler([
  181. at(20, 'turn/start', { turn: 3 }),
  182. at(21, 'step/start', { turn: 3, step: 1 }),
  183. at(22, 'llm/retry', {
  184. retryId: 'retry-hidden',
  185. turn: 3,
  186. step: 1,
  187. provider: 'fake',
  188. mode: 'normal',
  189. policyKey: 'fake-normal',
  190. retry: 1,
  191. maxRetries: 2,
  192. delayMs: 10,
  193. failure: { code: 'TRANSPORT', message: 'temporary' },
  194. }),
  195. ])
  196. expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined()
  197. const toolOnlyValue = assembler([
  198. at(30, 'turn/start', { turn: 4 }),
  199. at(31, 'step/start', { turn: 4, step: 1 }),
  200. at(32, 'assistant/chunk', {
  201. turn: 4,
  202. step: 1,
  203. chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' },
  204. }),
  205. at(33, 'assistant/message', {
  206. turn: 4,
  207. step: 1,
  208. message: {
  209. ...assistantMessage('assistant-tool-only', ''),
  210. content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
  211. },
  212. }, { surfaceOp: 'append' }),
  213. ])
  214. const toolOnlySnapshot = snapshot(toolOnlyValue)
  215. expect(toolOnlySnapshot.order).toEqual([])
  216. expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden')
  217. expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{
  218. kind: 'assistant',
  219. seq: 33,
  220. timing: { firstTokenTime: 1_700_000_000_032 },
  221. }])
  222. const interruptedToolOnlyValue = assembler([
  223. at(35, 'turn/start', { turn: 5 }),
  224. at(36, 'step/start', { turn: 5, step: 1 }),
  225. at(37, 'assistant/chunk', {
  226. turn: 5,
  227. step: 1,
  228. chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
  229. }),
  230. at(38, 'step/end', { turn: 5, step: 1 }),
  231. ])
  232. const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step')
  233. expect(interruptedToolOnly?.visibility).toBe('visible')
  234. expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' })
  235. const retryTimingValue = assembler([
  236. at(50, 'turn/start', { turn: 6 }),
  237. at(51, 'step/start', { turn: 6, step: 1 }),
  238. at(52, 'assistant/chunk', {
  239. turn: 6,
  240. step: 1,
  241. chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
  242. }),
  243. at(53, 'llm/retry', {
  244. retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal',
  245. policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
  246. failure: { code: 'TRANSPORT', message: 'temporary' },
  247. }),
  248. at(54, 'assistant/chunk', {
  249. turn: 6,
  250. step: 1,
  251. chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
  252. }),
  253. at(55, 'assistant/message', {
  254. turn: 6,
  255. step: 1,
  256. message: assistantMessage('assistant-retried', 'done'),
  257. }, { surfaceOp: 'append' }),
  258. ])
  259. const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode
  260. expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052)
  261. const partialWindow = assembler([
  262. at(40, 'assistant/chunk', {
  263. turn: 5,
  264. step: 2,
  265. chunk: { type: 'text-delta', index: 0, text: 'loaded partial' },
  266. }),
  267. at(41, 'step/end', { turn: 5, step: 2 }),
  268. ], true)
  269. const recovered = node(snapshot(partialWindow), 'assistant-step')
  270. expect(recovered?.data).toMatchObject({
  271. status: 'interrupted',
  272. blocks: [{ kind: 'text', text: 'loaded partial' }],
  273. })
  274. })
  275. it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => {
  276. const value = assembler([
  277. at(1, 'turn/start', { turn: 1 }),
  278. at(2, 'step/start', { turn: 1, step: 1 }),
  279. at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }),
  280. ])
  281. const runningSnapshot = snapshot(value)
  282. const running = node(runningSnapshot, 'tool-call')
  283. expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' })
  284. const order = runningSnapshot.order
  285. value.append(at(4, 'tool/result', {
  286. turn: 1,
  287. step: 1,
  288. message: toolResult('root', 'done', true),
  289. error: { name: 'ToolError', code: 'failed' },
  290. meta: { presentation: 'raw' },
  291. }, { surfaceOp: 'append' }))
  292. value.flush()
  293. const settledSnapshot = snapshot(value)
  294. const settled = node(settledSnapshot, 'tool-call')
  295. expect(settled?.key).toBe(running?.key)
  296. expect(settledSnapshot.order).toBe(order)
  297. expect((settled?.data as ToolChatData).root).toMatchObject({
  298. kind: 'tool-result',
  299. callId: 'root',
  300. call: { name: 'code', argsRaw: '{}' },
  301. content: [{ type: 'text', text: 'done' }],
  302. isError: true,
  303. error: { name: 'ToolError', code: 'failed' },
  304. meta: { presentation: 'raw' },
  305. })
  306. const history = assembler([
  307. at(14, 'tool/code-dispatch-start', {
  308. rootCallId: 'history-root',
  309. parentCallId: 'history-root',
  310. subCallId: 'child',
  311. name: 'read',
  312. arguments: { path: 'README.md' },
  313. }),
  314. at(15, 'tool/code-dispatch', {
  315. rootCallId: 'history-root',
  316. parentCallId: 'history-root',
  317. subCallId: 'child',
  318. name: 'read',
  319. arguments: { path: 'README.md' },
  320. isError: false,
  321. content: [{ type: 'text', text: 'contents' }],
  322. }),
  323. at(16, 'tool/result', {
  324. turn: 2,
  325. step: 1,
  326. message: toolResult('history-root', 'root done'),
  327. }, { surfaceOp: 'append' }),
  328. ], true)
  329. const before = node(snapshot(history), 'tool-call')
  330. expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
  331. { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
  332. ])
  333. history.prepend([
  334. at(10, 'turn/start', { turn: 2 }),
  335. at(11, 'step/start', { turn: 2, step: 1 }),
  336. at(13, 'tool/call', {
  337. turn: 2,
  338. step: 1,
  339. callId: 'history-root',
  340. name: 'code',
  341. arguments: '{}',
  342. }),
  343. ], false)
  344. history.flush()
  345. const after = node(snapshot(history), 'tool-call')
  346. expect(after?.key).toBe(before?.key)
  347. expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
  348. { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
  349. ])
  350. const firstChild = (after?.data as ToolChatData).root.subCalls[0]
  351. history.append(at(17, 'tool/code-dispatch-start', {
  352. rootCallId: 'history-root',
  353. parentCallId: 'history-root',
  354. subCallId: 'second-child',
  355. name: 'write',
  356. arguments: { path: 'out.txt' },
  357. }))
  358. history.flush()
  359. const withSecondChild = node(snapshot(history), 'tool-call')
  360. expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild)
  361. })
  362. it('prepends an older turn without replacing already materialized nodes', () => {
  363. const value = assembler([
  364. at(20, 'turn/start', { turn: 2 }),
  365. at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }),
  366. at(22, 'step/start', { turn: 2, step: 1 }),
  367. at(23, 'assistant/message', {
  368. turn: 2,
  369. step: 1,
  370. message: assistantMessage('newer-assistant', 'newer answer'),
  371. }, { surfaceOp: 'append' }),
  372. at(24, 'step/end', { turn: 2, step: 1 }),
  373. at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }),
  374. ], true)
  375. const before = snapshot(value)
  376. const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '')
  377. const store = before.nodes
  378. value.prepend([
  379. at(10, 'turn/start', { turn: 1 }),
  380. at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }),
  381. at(12, 'step/start', { turn: 1, step: 1 }),
  382. at(13, 'assistant/message', {
  383. turn: 1,
  384. step: 1,
  385. message: assistantMessage('older-assistant', 'older answer'),
  386. }, { surfaceOp: 'append' }),
  387. at(14, 'step/end', { turn: 1, step: 1 }),
  388. at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  389. ], false)
  390. value.flush()
  391. const after = snapshot(value)
  392. expect(after.nodes).toBe(store)
  393. expect(after.nodes.get(existing?.key ?? '')).toBe(existing)
  394. expect(after.order).toHaveLength(before.order.length + 3)
  395. expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
  396. 'user', 'assistant-step', 'turn-tail',
  397. 'user', 'assistant-step', 'turn-tail',
  398. ])
  399. })
  400. it('appends a later turn without replacing nodes from the completed turn', () => {
  401. const value = assembler([
  402. at(1, 'turn/start', { turn: 1 }),
  403. at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
  404. at(3, 'step/start', { turn: 1, step: 1 }),
  405. at(4, 'assistant/message', {
  406. turn: 1,
  407. step: 1,
  408. message: assistantMessage('first-assistant', 'first answer'),
  409. }, { surfaceOp: 'append' }),
  410. at(5, 'step/end', { turn: 1, step: 1 }),
  411. at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  412. ])
  413. const before = snapshot(value)
  414. const oldOrder = before.order
  415. const oldNodes = oldOrder.map(key => before.nodes.get(key))
  416. value.append(at(7, 'turn/start', { turn: 2 }))
  417. value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }))
  418. value.flush()
  419. const after = snapshot(value)
  420. expect(after.nodes).toBe(before.nodes)
  421. expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder)
  422. expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes)
  423. expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
  424. 'user', 'assistant-step', 'turn-tail', 'user',
  425. ])
  426. })
  427. it('keeps branching unavailable when a tool result follows the closing Assistant', () => {
  428. const value = assembler([
  429. at(1, 'turn/start', { turn: 1 }),
  430. at(2, 'step/start', { turn: 1, step: 1 }),
  431. at(3, 'assistant/message', {
  432. turn: 1,
  433. step: 1,
  434. message: assistantMessage('assistant-before-tool', 'running a tool'),
  435. }, { surfaceOp: 'append' }),
  436. at(4, 'tool/call', { turn: 1, step: 1, callId: 'late-tool', name: 'read', arguments: '{}' }),
  437. at(5, 'tool/result', {
  438. turn: 1,
  439. step: 1,
  440. message: toolResult('late-tool', 'done'),
  441. }, { surfaceOp: 'append' }),
  442. at(6, 'step/end', { turn: 1, step: 1 }),
  443. at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  444. ])
  445. const tail = node(snapshot(value), 'turn-tail')?.data as TurnTailChatData
  446. expect(tail.closing?.finalNode.seq).toBe(3)
  447. expect(tail.branchUnavailable).toBe(true)
  448. })
  449. it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => {
  450. const value = assembler([
  451. at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }),
  452. ], true)
  453. const before = node(snapshot(value), 'user')
  454. expect(before).toBeDefined()
  455. value.prepend([
  456. at(1, 'agent/inbox/spliced', {
  457. target: 'next-step',
  458. start: 0,
  459. inserted: [textMessage('steer-1', 'change direction')],
  460. }),
  461. at(2, 'agent/inbox/spliced', {
  462. target: 'next-step',
  463. start: 0,
  464. removedCount: 1,
  465. inserted: [],
  466. }),
  467. ], false)
  468. value.flush()
  469. const after = node(snapshot(value), 'steering')
  470. expect(after?.key).toBe(before?.key)
  471. expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' })
  472. expect(node(snapshot(value), 'user')).toBeUndefined()
  473. })
  474. it('orders claimed steering after the finalized Turn tail', () => {
  475. const steering = textMessage('steer-after-answer', 'change direction')
  476. const value = assembler([
  477. at(1, 'turn/start', { turn: 1 }),
  478. at(2, 'step/start', { turn: 1, step: 1 }),
  479. at(3, 'assistant/message', {
  480. turn: 1,
  481. step: 1,
  482. message: assistantMessage('assistant-before-steering', 'initial answer'),
  483. }, { surfaceOp: 'append' }),
  484. at(4, 'agent/inbox/spliced', {
  485. target: 'next-step',
  486. start: 0,
  487. inserted: [steering],
  488. }),
  489. at(5, 'agent/inbox/spliced', {
  490. target: 'next-step',
  491. start: 0,
  492. removedCount: 1,
  493. inserted: [],
  494. }),
  495. at(6, 'user/message', steering, { surfaceOp: 'append' }),
  496. at(7, 'step/end', { turn: 1, step: 1 }),
  497. at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  498. ])
  499. const current = snapshot(value)
  500. const steeringNode = node(current, 'steering')
  501. expect(steeringNode).toBeDefined()
  502. expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key)
  503. })
  504. it('classifies appended producer context from durable source metadata', () => {
  505. const value = assembler([
  506. at(1, 'user/message', {
  507. ...textMessage('skill-context', 'follow these instructions'),
  508. source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
  509. }, { surfaceOp: 'append' }),
  510. ])
  511. expect(node(snapshot(value), 'context')?.data).toMatchObject({
  512. kind: 'context',
  513. provenance: { role: 'inject', label: 'demo-skill' },
  514. form: 'instructions',
  515. })
  516. })
  517. it('associates each direct message with its immediately following session recall', () => {
  518. const value = assembler([
  519. at(1, 'user/message', textMessage('citing-research', '@Research notes what changed?'), { surfaceOp: 'append' }),
  520. at(2, 'user/message', {
  521. ...textMessage('research-context', 'snapshot'),
  522. source: {
  523. kind: 'session-reference',
  524. form: 'recall',
  525. version: 1,
  526. references: [{ sessionId: 'source-a', label: 'Research notes' }],
  527. },
  528. }, { surfaceOp: 'append' }),
  529. at(3, 'user/message', textMessage('citing-review', '@Review next'), { surfaceOp: 'append' }),
  530. at(4, 'user/message', {
  531. ...textMessage('review-context', 'snapshot'),
  532. source: {
  533. kind: 'session-reference',
  534. form: 'recall',
  535. version: 1,
  536. references: [{ sessionId: 'source-b', label: 'Review' }],
  537. },
  538. }, { surfaceOp: 'append' }),
  539. at(6, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }),
  540. ])
  541. const current = snapshot(value)
  542. const messages = [...current.nodes.values()]
  543. .filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
  544. const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user')
  545. expect(messages.map(candidate => candidate.kind)).toEqual(['user', 'context', 'user', 'context', 'user'])
  546. expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
  547. expect(users[1]?.data).toMatchObject({ referenceLabels: ['Review'] })
  548. expect(users[2]?.data).not.toHaveProperty('referenceLabels')
  549. })
  550. it('updates an already published direct node when its following recall arrives', () => {
  551. const value = assembler([
  552. at(1, 'user/message', textMessage('citing-user', '@Research notes what changed?'), { surfaceOp: 'append' }),
  553. ])
  554. const before = node(snapshot(value), 'user')
  555. expect(before?.data).not.toHaveProperty('referenceLabels')
  556. value.append(at(2, 'user/message', {
  557. ...textMessage('reference-context', 'snapshot'),
  558. source: {
  559. kind: 'session-reference',
  560. form: 'recall',
  561. version: 1,
  562. references: [{ sessionId: 'source-a', label: 'Research notes' }],
  563. },
  564. }, { surfaceOp: 'append' }))
  565. value.flush()
  566. const current = snapshot(value)
  567. const nodes = [...current.nodes.values()]
  568. .filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
  569. expect(nodes.map(candidate => candidate.kind)).toEqual(['user', 'context'])
  570. expect(nodes[0]?.key).toBe(before?.key)
  571. expect(nodes[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
  572. expect(current.legacy.nodes[0]).toMatchObject({ referenceLabels: ['Research notes'] })
  573. })
  574. it('associates a claimed steering message with its following recall', () => {
  575. const steering = textMessage('steering-reference', '@Research notes continue')
  576. const value = assembler([
  577. at(1, 'agent/inbox/spliced', {
  578. target: 'next-step',
  579. start: 0,
  580. inserted: [steering],
  581. }),
  582. at(2, 'agent/inbox/spliced', {
  583. target: 'next-step',
  584. start: 0,
  585. removedCount: 1,
  586. inserted: [],
  587. }),
  588. at(3, 'user/message', steering, { surfaceOp: 'append' }),
  589. at(4, 'user/message', {
  590. ...textMessage('steering-reference-context', 'snapshot'),
  591. source: {
  592. kind: 'session-reference',
  593. form: 'recall',
  594. version: 1,
  595. references: [{ sessionId: 'source-a', label: 'Research notes' }],
  596. },
  597. }, { surfaceOp: 'append' }),
  598. ])
  599. expect(node(snapshot(value), 'steering')?.data).toMatchObject({
  600. messageId: 'steering-reference',
  601. referenceLabels: ['Research notes'],
  602. })
  603. })
  604. it('keeps replacement copies out of Chat business nodes', () => {
  605. const value = assembler([
  606. at(1, 'turn/start', { turn: 1 }),
  607. at(2, 'step/start', { turn: 1, step: 1 }),
  608. at(3, 'user/message', {
  609. ...textMessage('replacement-user', 'model-only context'),
  610. source: { kind: 'plugin', plugin: 'foreign' },
  611. }, { surfaceOp: { op: 'replace', start: 1, end: 1 } }),
  612. at(4, 'assistant/message', {
  613. turn: 1,
  614. step: 1,
  615. message: assistantMessage('replacement-assistant', 'rewritten answer'),
  616. }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }),
  617. at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }),
  618. at(6, 'tool/result', {
  619. turn: 1,
  620. step: 1,
  621. message: toolResult('root', 'pruned result'),
  622. }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }),
  623. ])
  624. const current = snapshot(value)
  625. expect(node(current, 'user')).toBeUndefined()
  626. expect(node(current, 'context')).toBeUndefined()
  627. expect(node(current, 'assistant-step')).toBeUndefined()
  628. expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind')
  629. })
  630. it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => {
  631. const retry = assembler([
  632. at(1, 'turn/start', { turn: 1 }),
  633. at(2, 'step/start', { turn: 1, step: 1 }),
  634. at(3, 'llm/retry', {
  635. retryId: 'retry-1',
  636. turn: 1,
  637. step: 1,
  638. provider: 'fake',
  639. mode: 'normal',
  640. policyKey: 'fake-normal',
  641. retry: 1,
  642. maxRetries: 2,
  643. delayMs: 10,
  644. failure: { code: 'TRANSPORT', message: 'first' },
  645. }),
  646. at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }),
  647. at(5, 'llm/retry', {
  648. retryId: 'retry-1',
  649. turn: 1,
  650. step: 1,
  651. provider: 'fake',
  652. mode: 'normal',
  653. policyKey: 'fake-normal',
  654. retry: 2,
  655. maxRetries: 2,
  656. delayMs: 20,
  657. failure: { code: 'TRANSPORT', message: 'second' },
  658. }),
  659. at(6, 'step/end', { turn: 1, step: 1 }),
  660. at(7, 'turn/end', {
  661. turn: 1,
  662. reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
  663. }),
  664. ])
  665. const retryNode = node(snapshot(retry), 'model-retry')
  666. const retryData = retryNode?.data as RetryChatData
  667. expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled'])
  668. expect(node(snapshot(retry), 'turn-error')?.data).toMatchObject({
  669. kind: 'turn-error',
  670. turn: 1,
  671. message: 'failed',
  672. code: 'TRANSPORT',
  673. })
  674. const compactions = assembler([
  675. at(10, 'command/run', {
  676. commandId: 'command-1',
  677. name: 'compact',
  678. source: { kind: 'user' },
  679. }),
  680. at(11, 'compaction/start', {
  681. compactionId: 'manual-1',
  682. sourceCommandId: 'command-1',
  683. turn: null,
  684. }),
  685. at(12, 'compaction/summary', {
  686. compactionId: 'manual-1',
  687. sourceCommandId: 'command-1',
  688. summary: [{ type: 'text', text: 'manual summary' }],
  689. shadowedSeqs: [1, 2],
  690. shadowedTokenCount: 100,
  691. }),
  692. at(13, 'user/message', {
  693. ...textMessage('manual-checkpoint', 'checkpoint'),
  694. source: {
  695. kind: 'plugin',
  696. plugin: 'compact',
  697. compactionId: 'manual-1',
  698. sourceCommandId: 'command-1',
  699. },
  700. }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
  701. at(14, 'compaction/end', {
  702. compactionId: 'manual-1',
  703. sourceCommandId: 'command-1',
  704. turn: null,
  705. }),
  706. at(15, 'command/done', {
  707. commandId: 'command-1',
  708. kind: 'success',
  709. sourceEventSeq: 12,
  710. }),
  711. at(20, 'compaction/start', { compactionId: 'automatic-1', turn: null }),
  712. at(21, 'compaction/summary', {
  713. compactionId: 'automatic-1',
  714. summary: [{ type: 'text', text: 'automatic summary' }],
  715. shadowedSeqs: [3, 4],
  716. shadowedTokenCount: 200,
  717. }),
  718. at(22, 'user/message', {
  719. ...textMessage('automatic-checkpoint', 'checkpoint'),
  720. source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' },
  721. }, { surfaceOp: { op: 'replace', start: 3, end: 4 } }),
  722. at(23, 'compaction/end', { compactionId: 'automatic-1', turn: null }),
  723. ])
  724. const manual = node(snapshot(compactions), 'manual-compaction')
  725. expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({
  726. summary: 'manual summary',
  727. summaryEventSeq: 12,
  728. })
  729. const automatic = node(snapshot(compactions), 'compaction')
  730. expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 })
  731. expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1)
  732. })
  733. it('fills a landed compaction marker when an older page supplies its summary', () => {
  734. const value = assembler([
  735. at(13, 'user/message', {
  736. ...textMessage('checkpoint', 'checkpoint'),
  737. source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' },
  738. }, { surfaceOp: { op: 'replace', start: 1, end: 8 } }),
  739. ], true)
  740. const before = node(snapshot(value), 'compaction')
  741. expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null })
  742. value.prepend([
  743. at(9, 'compaction/start', { compactionId: 'compact-1', turn: null }),
  744. at(10, 'compaction/summary', {
  745. compactionId: 'compact-1',
  746. summary: [
  747. { type: 'text', text: 'older ' },
  748. { type: 'image', data: 'ignored' },
  749. { type: 'text', text: 'summary' },
  750. ],
  751. shadowedSeqs: [1, 2, 3],
  752. shadowedTokenCount: 42,
  753. }),
  754. ], false)
  755. value.flush()
  756. const after = node(snapshot(value), 'compaction')
  757. expect(after?.key).toBe(before?.key)
  758. expect(after?.data).toMatchObject({
  759. summary: 'older summary',
  760. summaryEventSeq: 10,
  761. shadowedItemCount: 3,
  762. shadowedTokenCount: 42,
  763. })
  764. })
  765. it('renders a historical compaction when its start remains outside the loaded window', () => {
  766. const value = assembler([
  767. at(10, 'compaction/summary', {
  768. compactionId: 'compact-windowed',
  769. summary: [{ type: 'text', text: 'loaded summary' }],
  770. shadowedSeqs: [1, 2, 3],
  771. shadowedTokenCount: 42,
  772. }),
  773. at(11, 'user/message', {
  774. ...textMessage('checkpoint-windowed', 'checkpoint'),
  775. source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' },
  776. }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
  777. ], true)
  778. expect(node(snapshot(value), 'compaction')?.data).toMatchObject({
  779. summary: 'loaded summary',
  780. summaryEventSeq: 10,
  781. shadowedItemCount: 3,
  782. shadowedTokenCount: 42,
  783. })
  784. })
  785. it('ignores legacy compaction transactions without correlation ids', () => {
  786. const value = assembler([
  787. at(10, 'compaction/start', { turn: null }),
  788. at(11, 'compaction/end', { turn: null, error: 'This operation was aborted' }),
  789. at(20, 'compaction/start', { turn: null }),
  790. at(21, 'compaction/summary', {
  791. summary: [{ type: 'text', text: 'legacy summary' }],
  792. shadowedSeqs: [1, 2, 3],
  793. shadowedTokenCount: 42,
  794. }),
  795. at(22, 'user/message', {
  796. ...textMessage('legacy-checkpoint', 'checkpoint'),
  797. source: { kind: 'plugin', plugin: 'compact' },
  798. }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
  799. at(23, 'compaction/end', { turn: null }),
  800. ], true)
  801. expect(node(snapshot(value), 'compaction')).toBeUndefined()
  802. })
  803. it('ignores legacy retry and code-dispatch events without correlation ids', () => {
  804. const value = assembler([
  805. at(10, 'llm/retry', {
  806. turn: 1,
  807. step: 1,
  808. provider: 'fake',
  809. mode: 'normal',
  810. policyKey: 'fake-normal',
  811. retry: 1,
  812. maxRetries: 2,
  813. delayMs: 10,
  814. failure: { code: 'TRANSPORT', message: 'first legacy retry' },
  815. }),
  816. at(11, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
  817. at(20, 'llm/retry', {
  818. turn: 2,
  819. step: 1,
  820. provider: 'fake',
  821. mode: 'normal',
  822. policyKey: 'fake-normal',
  823. retry: 1,
  824. maxRetries: 2,
  825. delayMs: 10,
  826. failure: { code: 'TRANSPORT', message: 'second legacy retry' },
  827. }),
  828. at(30, 'tool/code-dispatch-start', {
  829. parentCallId: 'root',
  830. subCallId: 'child',
  831. name: 'legacy-subcall',
  832. arguments: {},
  833. }),
  834. at(31, 'tool/code-dispatch', {
  835. parentCallId: 'root',
  836. subCallId: 'child',
  837. name: 'legacy-subcall',
  838. arguments: {},
  839. content: [],
  840. }),
  841. ], true)
  842. expect(node(snapshot(value), 'model-retry')).toBeUndefined()
  843. expect(node(snapshot(value), 'tool-call')).toBeUndefined()
  844. })
  845. it('renders the exhausted-retry turn error in a partial tail window and after prepending the chain', () => {
  846. const value = assembler([
  847. at(5, 'llm/retry', {
  848. retryId: 'retry-paged',
  849. turn: 1,
  850. step: 1,
  851. provider: 'fake',
  852. mode: 'normal',
  853. policyKey: 'fake-normal',
  854. retry: 2,
  855. maxRetries: 2,
  856. delayMs: 20,
  857. failure: { code: 'TRANSPORT', message: 'second' },
  858. }),
  859. at(6, 'step/end', { turn: 1, step: 1 }),
  860. at(7, 'turn/end', {
  861. turn: 1,
  862. reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
  863. }),
  864. ], true)
  865. expect(node(snapshot(value), 'model-retry')).toBeUndefined()
  866. expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({
  867. kind: 'turn-error',
  868. seq: 7,
  869. turn: 1,
  870. message: 'failed',
  871. code: 'TRANSPORT',
  872. })
  873. value.prepend([
  874. at(1, 'turn/start', { turn: 1 }),
  875. at(2, 'step/start', { turn: 1, step: 1 }),
  876. at(3, 'llm/retry', {
  877. retryId: 'retry-paged',
  878. turn: 1,
  879. step: 1,
  880. provider: 'fake',
  881. mode: 'normal',
  882. policyKey: 'fake-normal',
  883. retry: 1,
  884. maxRetries: 2,
  885. delayMs: 10,
  886. failure: { code: 'TRANSPORT', message: 'first' },
  887. }),
  888. at(4, 'llm/retry-started', {
  889. retryId: 'retry-paged', turn: 1, step: 1, retry: 1,
  890. }),
  891. ], false)
  892. value.flush()
  893. const retry = node(snapshot(value), 'model-retry')
  894. expect((retry?.data as RetryChatData).attempts).toHaveLength(2)
  895. expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({
  896. kind: 'turn-error',
  897. seq: 7,
  898. turn: 1,
  899. message: 'failed',
  900. code: 'TRANSPORT',
  901. })
  902. })
  903. it('materializes a max-tokens notice and keeps completed and error turns clean', () => {
  904. const value = assembler([
  905. at(1, 'turn/start', { turn: 1 }),
  906. at(2, 'step/start', { turn: 1, step: 1 }),
  907. at(3, 'assistant/message', {
  908. turn: 1, step: 1, message: assistantMessage('a1', 'truncated answer'),
  909. }, { surfaceOp: 'append' }),
  910. at(4, 'step/end', { turn: 1, step: 1 }),
  911. at(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } }),
  912. ])
  913. const notice = node(snapshot(value), 'turn-max-tokens')
  914. expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 })
  915. expect(node(snapshot(value), 'turn-error')).toBeUndefined()
  916. // The tail stays the turn's last node so its branch action survives; the
  917. // notice slots between the truncated closing Assistant and the tail.
  918. const tail = node(snapshot(value), 'turn-tail')
  919. expect(notice?.anchorSeq).toBeLessThan(tail?.anchorSeq ?? Number.NEGATIVE_INFINITY)
  920. expect(notice?.anchorSeq).toBeGreaterThan(3)
  921. const completed = assembler([
  922. at(1, 'turn/start', { turn: 1 }),
  923. at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  924. ])
  925. expect(node(snapshot(completed), 'turn-max-tokens')).toBeUndefined()
  926. const failed = assembler([
  927. at(1, 'turn/start', { turn: 1 }),
  928. at(2, 'turn/end', {
  929. turn: 1,
  930. reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
  931. }),
  932. ])
  933. expect(node(snapshot(failed), 'turn-max-tokens')).toBeUndefined()
  934. expect(node(snapshot(failed), 'turn-error')).toBeDefined()
  935. })
  936. it('keeps the max-tokens notice when the window starts after the owning turn/start', () => {
  937. const value = assembler([
  938. at(9, 'turn/end', { turn: 3, reason: { kind: 'max-tokens' } }),
  939. ], true)
  940. const notice = node(snapshot(value), 'turn-max-tokens')
  941. expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 9, turn: 3 })
  942. })
  943. it('pins the max-tokens Definition edges the engine cannot reach', () => {
  944. // The engine only hands start the single matched turn/end and never emits
  945. // update Matches for this kind; these direct calls pin the declared
  946. // behavior of both required Definition members anyway.
  947. const match = (seq: number, type: string, data: unknown) => ({
  948. event: { seq, time: seq * 1_000, type, data },
  949. role: 'start',
  950. location: undefined,
  951. }) as unknown as Parameters<typeof turnMaxTokensDefinition.start>[1]
  952. const context = (state: unknown, matches: unknown[] = []) => ({
  953. key: 'k', kind: 'turn-max-tokens', id: '1', matches, start: undefined, state, current: new Map(),
  954. }) as unknown as Parameters<NonNullable<typeof turnMaxTokensDefinition.buildViewNode>>[0]
  955. const reader = { previous: () => undefined }
  956. expect(() => turnMaxTokensDefinition.start(context(undefined), match(1, 'turn/start', { turn: 1 }), reader))
  957. .toThrow('turn-max-tokens start requires a max-tokens turn/end')
  958. const state = { turn: 1, seq: 5, time: 5_000 }
  959. expect(turnMaxTokensDefinition.update(
  960. context(state) as Parameters<typeof turnMaxTokensDefinition.update>[0],
  961. match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  962. )).toBe(state)
  963. expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull()
  964. })
  965. it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
  966. const value = assembler([
  967. at(12, 'tool/code-dispatch-start', {
  968. rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
  969. }),
  970. at(13, 'tool/code-dispatch', {
  971. rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
  972. isError: false, content: [{ type: 'text', text: 'child result' }],
  973. }),
  974. at(14, 'tool/result', {
  975. turn: 1,
  976. step: 1,
  977. message: toolResult('root', 'root result'),
  978. }, { surfaceOp: 'append' }),
  979. at(20, 'compaction/summary', {
  980. compactionId: 'manual-1',
  981. sourceCommandId: 'command-1',
  982. summary: [{ type: 'text', text: 'manual summary' }],
  983. shadowedSeqs: [1, 2],
  984. shadowedTokenCount: 100,
  985. }),
  986. at(21, 'user/message', {
  987. ...textMessage('manual-checkpoint', 'checkpoint'),
  988. source: {
  989. kind: 'plugin',
  990. plugin: 'compact',
  991. compactionId: 'manual-1',
  992. sourceCommandId: 'command-1',
  993. },
  994. }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
  995. at(22, 'command/done', {
  996. commandId: 'command-1',
  997. kind: 'success',
  998. sourceEventSeq: 20,
  999. }),
  1000. ], true)
  1001. const tool = node(snapshot(value), 'tool-call')
  1002. const root = (tool?.data as ToolChatData).root
  1003. expect(root.subCalls).toHaveLength(1)
  1004. expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' })
  1005. const manual = node(snapshot(value), 'manual-compaction')
  1006. expect((manual?.data as ManualCompactionChatData)).toMatchObject({
  1007. command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } },
  1008. compaction: { summary: 'manual summary', summaryEventSeq: 20 },
  1009. })
  1010. })
  1011. })