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

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