conversation-node-definitions.spec.ts 30 KB

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