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

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558
  1. import { describe, expect, it } from 'vitest'
  2. import type {
  3. ChatConversationViewNode, ChatSnapshot,
  4. } from '@deepseek-ai/dsh-client-ui-chat/client'
  5. import type {
  6. SessionEventLikeEntry, SessionLiveEventEntry,
  7. } from '@deepseek-ai/dsh-api-session-controller/client'
  8. import type {
  9. ChunkRowEvent,
  10. } from '@deepseek-ai/dsh-api-session-controller/types'
  11. import {
  12. ConversationNodeAssembler,
  13. type ConversationNodeDefinition,
  14. type ConversationViewDefinition,
  15. } from '@deepseek-ai/dsh-client-ui-conversation/client'
  16. import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
  17. import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
  18. import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
  19. import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
  20. import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
  21. import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
  22. import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
  23. import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
  24. import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
  25. import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
  26. import { requestPromptDefinition } from '../src/client/conversation-nodes/request-prompt.ts'
  27. import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
  28. import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
  29. import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
  30. import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
  31. import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
  32. import type {
  33. AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
  34. } from '../src/client/contract/chat-nodes.ts'
  35. const DEFINITIONS: readonly ConversationNodeDefinition[] = [
  36. nextTurnInboxDefinition,
  37. nextStepInboxDefinition,
  38. messageDefinition,
  39. requestPromptDefinition(inspectRequestPrompt),
  40. assistantDefinition,
  41. toolDefinition,
  42. commandDefinition,
  43. compactionDefinition,
  44. retryDefinition,
  45. turnErrorDefinition,
  46. turnMaxTokensDefinition,
  47. turnTailDefinition,
  48. ]
  49. class TestEventDefinitions {
  50. entries(): readonly ConversationNodeDefinition[] {
  51. return DEFINITIONS
  52. }
  53. fallbackEntry(): ConversationNodeDefinition {
  54. return unknownFallbackDefinition
  55. }
  56. }
  57. class TestViewDefinitions {
  58. entries(): readonly ConversationViewDefinition[] {
  59. return [chatViewDefinition]
  60. }
  61. }
  62. function at(
  63. seq: number,
  64. type: string,
  65. data: unknown,
  66. extra: Record<string, unknown> = {},
  67. ): SessionLiveEventEntry {
  68. return {
  69. type: 'event',
  70. event: {
  71. seq,
  72. time: 1_700_000_000_000 + seq,
  73. type,
  74. data,
  75. ...extra,
  76. } as unknown as SessionEvent,
  77. }
  78. }
  79. function chunkEntry(row: ChunkRow): SessionEventLikeEntry {
  80. return {
  81. type: 'chunks',
  82. event: {
  83. type: `chunkrow/${row.type}`,
  84. seq: row.seq0,
  85. time: row.time0,
  86. data: row.data,
  87. } as ChunkRowEvent,
  88. }
  89. }
  90. function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
  91. return packChunkRuns(entries.map(entry => entry.event)).map((record) => {
  92. return isChunkRow(record) ? chunkEntry(record) : { type: 'event', event: record }
  93. })
  94. }
  95. function assembler(entries: readonly SessionEventLikeEntry[] = [], hasMore = false): ConversationNodeAssembler {
  96. const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
  97. value.replaceWindow(entries, hasMore)
  98. value.flush()
  99. return value
  100. }
  101. function snapshot(value: ConversationNodeAssembler): ChatSnapshot {
  102. const current = value.snapshot('chat') as ChatSnapshot | undefined
  103. if (current === undefined) throw new Error('chat view was not registered')
  104. return current
  105. }
  106. function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
  107. return value.nodes.values().find(candidate => candidate.kind === kind)
  108. }
  109. function textMessage(id: string, text: string) {
  110. return {
  111. id,
  112. role: 'user',
  113. content: [{ type: 'text', text }],
  114. source: { kind: 'user' },
  115. }
  116. }
  117. function assistantMessage(id: string, text: string) {
  118. return {
  119. id,
  120. role: 'assistant',
  121. content: [{ type: 'text', text }],
  122. source: { kind: 'model', provider: 'fake', model: 'fake' },
  123. }
  124. }
  125. function toolResult(callId: string, text: string, isError = false) {
  126. return {
  127. id: `result-${callId}`,
  128. role: 'user',
  129. source: { kind: 'tool', callId },
  130. content: [{
  131. type: 'tool-result',
  132. toolCallId: callId,
  133. content: [{ type: 'text', text }],
  134. isError,
  135. }],
  136. }
  137. }
  138. describe('built-in conversation node Definitions', () => {
  139. it('rejects an unrelated event passed directly to the request-prompt start', () => {
  140. const input = at(1, 'turn/start', { turn: 1 })
  141. const invalidStart = {
  142. ...input,
  143. role: 'start' as const,
  144. location: { kind: 'session' as const },
  145. }
  146. expect(() => requestPromptDefinition(inspectRequestPrompt).start({} as never, invalidStart, {} as never))
  147. .toThrow('request-prompt start requires request/header')
  148. })
  149. it('keeps ordinary command-only history inactive for the Conversation shell', () => {
  150. const value = assembler([
  151. at(1, 'command/run', {
  152. commandId: 'command-1',
  153. name: 'help',
  154. source: { kind: 'user' },
  155. }),
  156. at(2, 'command/done', {
  157. commandId: 'command-1',
  158. kind: 'success',
  159. }),
  160. ])
  161. const current = snapshot(value)
  162. expect(current.order).toHaveLength(1)
  163. expect(current.nodes.get(current.order[0] ?? '')?.kind).toBe('command')
  164. expect(chatViewDefinition.isActive?.(current)).toBe(false)
  165. })
  166. it('keeps the Turn rail projection current when a chunk updates one node in place', () => {
  167. const value = assembler([
  168. at(1, 'turn/start', { turn: 1 }),
  169. at(2, 'user/message', textMessage('user-1', 'navigate here'), { surfaceOp: 'append' }),
  170. at(3, 'step/start', { turn: 1, step: 1 }),
  171. at(4, 'assistant/chunk', {
  172. turn: 1,
  173. step: 1,
  174. chunk: { type: 'text-delta', index: 0, text: 'first' },
  175. }),
  176. ])
  177. const opening = snapshot(value).navigation.items()
  178. expect(opening).toHaveLength(1)
  179. expect(opening[0]?.turn).toBe(1)
  180. expect(opening[0]?.prompt).toBe('navigate here')
  181. expect(opening[0]?.response).toBe('first')
  182. // Content-only upsert: the node keeps its key, so the rail's preview has to
  183. // follow the in-place update rather than the last structural publication.
  184. value.append(at(5, 'assistant/chunk', {
  185. turn: 1,
  186. step: 1,
  187. chunk: { type: 'text-delta', index: 0, text: ' and more' },
  188. }))
  189. value.flush()
  190. const streamed = snapshot(value).navigation.items()
  191. expect(streamed[0]?.response).toBe('first and more')
  192. expect(streamed).not.toBe(opening)
  193. })
  194. it('bounds each rail preview instead of copying the whole transcript', () => {
  195. const long = 'x'.repeat(400)
  196. const value = assembler([
  197. at(1, 'turn/start', { turn: 1 }),
  198. at(2, 'user/message', textMessage('user-1', long), { surfaceOp: 'append' }),
  199. ])
  200. const items = snapshot(value).navigation.items()
  201. expect(items[0]?.prompt.length).toBe(160)
  202. })
  203. it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
  204. const value = assembler([
  205. at(1, 'turn/start', { turn: 1 }),
  206. at(2, 'step/start', { turn: 1, step: 1 }),
  207. at(3, 'assistant/chunk', {
  208. turn: 1,
  209. step: 1,
  210. chunk: { type: 'text-delta', index: 0, text: 'streaming' },
  211. }),
  212. ])
  213. const runningSnapshot = snapshot(value)
  214. const running = node(runningSnapshot, 'assistant-step')
  215. expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] })
  216. const order = runningSnapshot.order
  217. value.append(at(4, 'assistant/message', {
  218. turn: 1,
  219. step: 1,
  220. message: assistantMessage('assistant-1', 'settled'),
  221. }, { surfaceOp: 'append' }))
  222. value.flush()
  223. const settledSnapshot = snapshot(value)
  224. const settled = node(settledSnapshot, 'assistant-step')
  225. expect(settled?.key).toBe(running?.key)
  226. expect(settledSnapshot.order).toBe(order)
  227. expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] })
  228. const interruptedValue = assembler([
  229. at(10, 'turn/start', { turn: 2 }),
  230. at(11, 'step/start', { turn: 2, step: 1 }),
  231. at(12, 'assistant/chunk', {
  232. turn: 2,
  233. step: 1,
  234. chunk: { type: 'text-delta', index: 0, text: 'partial' },
  235. }),
  236. at(13, 'step/end', { turn: 2, step: 1 }),
  237. ])
  238. const interrupted = node(snapshot(interruptedValue), 'assistant-step')
  239. expect(interrupted?.data).toMatchObject({ status: 'interrupted' })
  240. expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
  241. const markedValue = assembler([
  242. at(20, 'turn/start', { turn: 3 }),
  243. at(21, 'step/start', { turn: 3, step: 1 }),
  244. at(22, 'assistant/message', {
  245. turn: 3,
  246. step: 1,
  247. message: assistantMessage('assistant-3', 'cut short'),
  248. interrupted: true,
  249. }, { surfaceOp: 'append' }),
  250. ])
  251. const marked = node(snapshot(markedValue), 'assistant-step')
  252. expect(marked?.data).toMatchObject({ status: 'interrupted', blocks: [{ kind: 'text', text: 'cut short' }] })
  253. expect((marked?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
  254. const hiddenValue = assembler([
  255. at(20, 'turn/start', { turn: 3 }),
  256. at(21, 'step/start', { turn: 3, step: 1 }),
  257. at(22, 'llm/retry', {
  258. retryId: 'retry-hidden',
  259. turn: 3,
  260. step: 1,
  261. provider: 'fake',
  262. mode: 'normal',
  263. policyKey: 'fake-normal',
  264. retry: 1,
  265. maxRetries: 2,
  266. delayMs: 10,
  267. failure: { code: 'TRANSPORT', message: 'temporary' },
  268. }),
  269. ])
  270. expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined()
  271. const toolOnlyValue = assembler([
  272. at(30, 'turn/start', { turn: 4 }),
  273. at(31, 'step/start', { turn: 4, step: 1 }),
  274. at(32, 'assistant/chunk', {
  275. turn: 4,
  276. step: 1,
  277. chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' },
  278. }),
  279. at(33, 'assistant/message', {
  280. turn: 4,
  281. step: 1,
  282. message: {
  283. ...assistantMessage('assistant-tool-only', ''),
  284. content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
  285. },
  286. }, { surfaceOp: 'append' }),
  287. ])
  288. const toolOnlySnapshot = snapshot(toolOnlyValue)
  289. expect(toolOnlySnapshot.order).toEqual([])
  290. expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden')
  291. expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{
  292. kind: 'assistant',
  293. seq: 33,
  294. timing: { firstTokenTime: 1_700_000_000_032 },
  295. }])
  296. const interruptedToolOnlyValue = assembler([
  297. at(35, 'turn/start', { turn: 5 }),
  298. at(36, 'step/start', { turn: 5, step: 1 }),
  299. at(37, 'assistant/chunk', {
  300. turn: 5,
  301. step: 1,
  302. chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
  303. }),
  304. at(38, 'step/end', { turn: 5, step: 1 }),
  305. ])
  306. const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step')
  307. expect(interruptedToolOnly?.visibility).toBe('visible')
  308. expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' })
  309. const retryTimingValue = assembler([
  310. at(50, 'turn/start', { turn: 6 }),
  311. at(51, 'step/start', { turn: 6, step: 1 }),
  312. at(52, 'assistant/chunk', {
  313. turn: 6,
  314. step: 1,
  315. chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
  316. }),
  317. at(53, 'llm/retry', {
  318. retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal',
  319. policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
  320. failure: { code: 'TRANSPORT', message: 'temporary' },
  321. }),
  322. at(54, 'assistant/chunk', {
  323. turn: 6,
  324. step: 1,
  325. chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
  326. }),
  327. at(55, 'assistant/message', {
  328. turn: 6,
  329. step: 1,
  330. message: assistantMessage('assistant-retried', 'done'),
  331. }, { surfaceOp: 'append' }),
  332. ])
  333. const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode
  334. expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052)
  335. const partialWindow = assembler([
  336. at(40, 'assistant/chunk', {
  337. turn: 5,
  338. step: 2,
  339. chunk: { type: 'text-delta', index: 0, text: 'loaded partial' },
  340. }),
  341. at(41, 'step/end', { turn: 5, step: 2 }),
  342. ], true)
  343. const recovered = node(snapshot(partialWindow), 'assistant-step')
  344. expect(recovered?.data).toMatchObject({
  345. status: 'interrupted',
  346. blocks: [{ kind: 'text', text: 'loaded partial' }],
  347. })
  348. })
  349. it('folds packed Assistant runs to the same Chat and Turn Tail state as scalar deltas', () => {
  350. const runningHistory = [
  351. at(1, 'turn/start', { turn: 1 }),
  352. at(2, 'step/start', { turn: 1, step: 1 }),
  353. at(3, 'assistant/chunk', {
  354. turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
  355. }, { time: 1_000 }),
  356. at(4, 'assistant/chunk', {
  357. turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
  358. }, { time: 1_000 }),
  359. at(5, 'assistant/chunk', {
  360. turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\t' },
  361. }, { time: 995 }),
  362. at(6, 'assistant/chunk', {
  363. turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' },
  364. }, { time: 1_004 }),
  365. at(7, 'assistant/chunk', {
  366. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: '' },
  367. }),
  368. at(8, 'assistant/chunk', {
  369. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' },
  370. }),
  371. at(9, 'assistant/chunk', {
  372. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'ing' },
  373. }),
  374. at(10, 'assistant/chunk', {
  375. turn: 1, step: 1,
  376. chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '' },
  377. }),
  378. at(11, 'assistant/chunk', {
  379. turn: 1, step: 1,
  380. chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '{"x":' },
  381. }),
  382. at(12, 'assistant/chunk', {
  383. turn: 1, step: 1,
  384. chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '1}' },
  385. }),
  386. ]
  387. const scalar = assembler(runningHistory)
  388. const packedHistory = packedInputs(runningHistory)
  389. expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(3)
  390. const packed = assembler(packedHistory)
  391. expect(snapshot(packed)).toEqual(snapshot(scalar))
  392. const running = node(snapshot(packed), 'assistant-step')
  393. expect(running).toMatchObject({ anchorSeq: 6 })
  394. expect(running?.data).toMatchObject({
  395. time: 1_004,
  396. blocks: [
  397. { kind: 'text', text: ' \tanswer' },
  398. { kind: 'reasoning', text: 'thinking' },
  399. { kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
  400. ],
  401. })
  402. for (const value of [scalar, packed]) {
  403. value.append(at(13, 'step/end', { turn: 1, step: 1 }))
  404. value.append(at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
  405. value.flush()
  406. }
  407. expect(snapshot(packed)).toEqual(snapshot(scalar))
  408. expect(node(snapshot(packed), 'turn-tail')?.anchorSeq).toBe(12.2)
  409. const partialHistory = [
  410. ...runningHistory.slice(2),
  411. at(13, 'step/end', { turn: 1, step: 1 }),
  412. at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  413. ]
  414. const partialScalar = snapshot(assembler(partialHistory, true))
  415. const partialPacked = snapshot(assembler(packedInputs(partialHistory), true))
  416. expect(partialPacked).toEqual(partialScalar)
  417. expect(node(partialPacked, 'assistant-step')?.data).toMatchObject({ status: 'interrupted' })
  418. expect(node(partialPacked, 'turn-tail')?.anchorSeq).toBe(12.2)
  419. const finalizedHistory = [
  420. at(20, 'turn/start', { turn: 2 }),
  421. at(21, 'step/start', { turn: 2, step: 1 }),
  422. at(22, 'assistant/chunk', {
  423. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
  424. }, { time: 2_000 }),
  425. at(23, 'assistant/chunk', {
  426. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
  427. }, { time: 1_999 }),
  428. at(24, 'assistant/chunk', {
  429. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' },
  430. }, { time: 2_000 }),
  431. at(25, 'llm/retry', {
  432. retryId: 'packed-retry', turn: 2, step: 1, provider: 'fake', mode: 'normal',
  433. policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
  434. failure: { code: 'TRANSPORT', message: 'temporary' },
  435. }),
  436. at(26, 'assistant/chunk', {
  437. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
  438. }),
  439. at(27, 'assistant/chunk', {
  440. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'second' },
  441. }),
  442. at(28, 'assistant/chunk', {
  443. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' attempt' },
  444. }),
  445. at(29, 'assistant/message', {
  446. turn: 2, step: 1, message: assistantMessage('packed-final', 'done'),
  447. }, { surfaceOp: 'append' }),
  448. ]
  449. const finalizedScalar = snapshot(assembler(finalizedHistory))
  450. const finalizedPacked = snapshot(assembler(packedInputs(finalizedHistory)))
  451. expect(finalizedPacked).toEqual(finalizedScalar)
  452. const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
  453. expect(finalNode?.timing?.firstTokenTime).toBe(1_999)
  454. const namedToolHistory = [
  455. at(40, 'turn/start', { turn: 3 }),
  456. at(41, 'step/start', { turn: 3, step: 1 }),
  457. ...[42, 43, 44].map(seq => at(seq, 'assistant/chunk', {
  458. turn: 3, step: 1,
  459. chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
  460. }, { time: 4_000 + seq - 42 })),
  461. at(45, 'assistant/message', {
  462. turn: 3,
  463. step: 1,
  464. message: {
  465. ...assistantMessage('named-tool-final', ''),
  466. content: [{ type: 'tool-call', id: 'call-2', name: 'read', arguments: '' }],
  467. },
  468. }, { surfaceOp: 'append' }),
  469. ]
  470. const namedToolScalar = snapshot(assembler(namedToolHistory))
  471. const namedToolPacked = snapshot(assembler(packedInputs(namedToolHistory)))
  472. expect(namedToolPacked).toEqual(namedToolScalar)
  473. const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
  474. expect(namedTool?.timing?.firstTokenTime).toBe(4_000)
  475. })
  476. it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => {
  477. const value = assembler([
  478. at(1, 'turn/start', { turn: 1 }),
  479. at(2, 'step/start', { turn: 1, step: 1 }),
  480. at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }),
  481. ])
  482. const runningSnapshot = snapshot(value)
  483. const running = node(runningSnapshot, 'tool-call')
  484. expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' })
  485. const order = runningSnapshot.order
  486. value.append(at(4, 'tool/result', {
  487. turn: 1,
  488. step: 1,
  489. message: toolResult('root', 'done', true),
  490. error: { name: 'ToolError', code: 'failed' },
  491. meta: { presentation: 'raw' },
  492. }, { surfaceOp: 'append' }))
  493. value.flush()
  494. const settledSnapshot = snapshot(value)
  495. const settled = node(settledSnapshot, 'tool-call')
  496. expect(settled?.key).toBe(running?.key)
  497. expect(settledSnapshot.order).toBe(order)
  498. expect((settled?.data as ToolChatData).root).toMatchObject({
  499. kind: 'tool-result',
  500. callId: 'root',
  501. call: { name: 'code', argsRaw: '{}' },
  502. content: [{ type: 'text', text: 'done' }],
  503. isError: true,
  504. error: { name: 'ToolError', code: 'failed' },
  505. meta: { presentation: 'raw' },
  506. })
  507. const history = assembler([
  508. at(14, 'tool/code-dispatch-start', {
  509. rootCallId: 'history-root',
  510. parentCallId: 'history-root',
  511. subCallId: 'child',
  512. name: 'read',
  513. arguments: { path: 'README.md' },
  514. }),
  515. at(15, 'tool/code-dispatch', {
  516. rootCallId: 'history-root',
  517. parentCallId: 'history-root',
  518. subCallId: 'child',
  519. name: 'read',
  520. arguments: { path: 'README.md' },
  521. isError: false,
  522. content: [{ type: 'text', text: 'contents' }],
  523. }),
  524. at(16, 'tool/result', {
  525. turn: 2,
  526. step: 1,
  527. message: toolResult('history-root', 'root done'),
  528. }, { surfaceOp: 'append' }),
  529. ], true)
  530. const before = node(snapshot(history), 'tool-call')
  531. expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
  532. { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
  533. ])
  534. history.prepend([
  535. at(10, 'turn/start', { turn: 2 }),
  536. at(11, 'step/start', { turn: 2, step: 1 }),
  537. at(13, 'tool/call', {
  538. turn: 2,
  539. step: 1,
  540. callId: 'history-root',
  541. name: 'code',
  542. arguments: '{}',
  543. }),
  544. ], false)
  545. history.flush()
  546. const after = node(snapshot(history), 'tool-call')
  547. expect(after?.key).toBe(before?.key)
  548. expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
  549. { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
  550. ])
  551. const firstChild = (after?.data as ToolChatData).root.subCalls[0]
  552. history.append(at(17, 'tool/code-dispatch-start', {
  553. rootCallId: 'history-root',
  554. parentCallId: 'history-root',
  555. subCallId: 'second-child',
  556. name: 'write',
  557. arguments: { path: 'out.txt' },
  558. }))
  559. history.flush()
  560. const withSecondChild = node(snapshot(history), 'tool-call')
  561. expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild)
  562. })
  563. it('prepends an older turn without replacing already materialized nodes', () => {
  564. const value = assembler([
  565. at(20, 'turn/start', { turn: 2 }),
  566. at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }),
  567. at(22, 'step/start', { turn: 2, step: 1 }),
  568. at(23, 'assistant/message', {
  569. turn: 2,
  570. step: 1,
  571. message: assistantMessage('newer-assistant', 'newer answer'),
  572. }, { surfaceOp: 'append' }),
  573. at(24, 'step/end', { turn: 2, step: 1 }),
  574. at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }),
  575. ], true)
  576. const before = snapshot(value)
  577. const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '')
  578. const store = before.nodes
  579. value.prepend([
  580. at(10, 'turn/start', { turn: 1 }),
  581. at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }),
  582. at(12, 'step/start', { turn: 1, step: 1 }),
  583. at(13, 'assistant/message', {
  584. turn: 1,
  585. step: 1,
  586. message: assistantMessage('older-assistant', 'older answer'),
  587. }, { surfaceOp: 'append' }),
  588. at(14, 'step/end', { turn: 1, step: 1 }),
  589. at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  590. ], false)
  591. value.flush()
  592. const after = snapshot(value)
  593. expect(after.nodes).toBe(store)
  594. expect(after.nodes.get(existing?.key ?? '')).toBe(existing)
  595. expect(after.order).toHaveLength(before.order.length + 3)
  596. expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
  597. 'user', 'assistant-step', 'turn-tail',
  598. 'user', 'assistant-step', 'turn-tail',
  599. ])
  600. })
  601. it('appends a later turn without replacing nodes from the completed turn', () => {
  602. const value = assembler([
  603. at(1, 'turn/start', { turn: 1 }),
  604. at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
  605. at(3, 'step/start', { turn: 1, step: 1 }),
  606. at(4, 'assistant/message', {
  607. turn: 1,
  608. step: 1,
  609. message: assistantMessage('first-assistant', 'first answer'),
  610. }, { surfaceOp: 'append' }),
  611. at(5, 'step/end', { turn: 1, step: 1 }),
  612. at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  613. ])
  614. const before = snapshot(value)
  615. const oldOrder = before.order
  616. const oldNodes = oldOrder.map(key => before.nodes.get(key))
  617. value.append(at(7, 'turn/start', { turn: 2 }))
  618. value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }))
  619. value.flush()
  620. const after = snapshot(value)
  621. expect(after.nodes).toBe(before.nodes)
  622. expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder)
  623. expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes)
  624. expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
  625. 'user', 'assistant-step', 'turn-tail', 'user',
  626. ])
  627. })
  628. it('keeps branching unavailable when a tool result follows the closing Assistant', () => {
  629. const value = assembler([
  630. at(1, 'turn/start', { turn: 1 }),
  631. at(2, 'step/start', { turn: 1, step: 1 }),
  632. at(3, 'assistant/message', {
  633. turn: 1,
  634. step: 1,
  635. message: assistantMessage('assistant-before-tool', 'running a tool'),
  636. }, { surfaceOp: 'append' }),
  637. at(4, 'tool/call', { turn: 1, step: 1, callId: 'late-tool', name: 'read', arguments: '{}' }),
  638. at(5, 'tool/result', {
  639. turn: 1,
  640. step: 1,
  641. message: toolResult('late-tool', 'done'),
  642. }, { surfaceOp: 'append' }),
  643. at(6, 'step/end', { turn: 1, step: 1 }),
  644. at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  645. ])
  646. const tail = node(snapshot(value), 'turn-tail')?.data as TurnTailChatData
  647. expect(tail.closing?.finalNode.seq).toBe(3)
  648. expect(tail.branchUnavailable).toBe(true)
  649. })
  650. it('publishes exact Turn usage only after pagination supplies the full lifecycle window', () => {
  651. const value = assembler([
  652. at(3, 'assistant/message', {
  653. turn: 1,
  654. step: 1,
  655. message: assistantMessage('usage-assistant', 'done'),
  656. usage: {
  657. inputTokens: 10,
  658. outputTokens: 4,
  659. totalTokens: 17,
  660. cacheReadTokens: 2,
  661. cacheWriteTokens: 1,
  662. reasoningTokens: 1,
  663. },
  664. }, { surfaceOp: 'append' }),
  665. at(4, 'step/end', { turn: 1, step: 1 }),
  666. at(5, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  667. ], true)
  668. expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toBeUndefined()
  669. value.prepend([
  670. at(1, 'turn/start', { turn: 1 }),
  671. at(2, 'step/start', { turn: 1, step: 1 }),
  672. ], false)
  673. value.flush()
  674. expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toEqual({
  675. uncachedInputTokens: 10,
  676. outputTokens: 4,
  677. totalTokens: 17,
  678. cacheReadTokens: 2,
  679. cacheWriteTokens: 1,
  680. reasoningTokens: 1,
  681. routes: [{ provider: 'fake', model: 'fake' }],
  682. })
  683. })
  684. it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => {
  685. const value = assembler([
  686. at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }),
  687. ], true)
  688. const before = node(snapshot(value), 'user')
  689. expect(before).toBeDefined()
  690. value.prepend([
  691. at(1, 'agent/inbox/spliced', {
  692. target: 'next-step',
  693. start: 0,
  694. inserted: [textMessage('steer-1', 'change direction')],
  695. }),
  696. at(2, 'agent/inbox/spliced', {
  697. target: 'next-step',
  698. start: 0,
  699. removedCount: 1,
  700. inserted: [],
  701. }),
  702. ], false)
  703. value.flush()
  704. const after = node(snapshot(value), 'steering')
  705. expect(after?.key).toBe(before?.key)
  706. expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' })
  707. expect(node(snapshot(value), 'user')).toBeUndefined()
  708. })
  709. it('orders claimed steering after the finalized Turn tail', () => {
  710. const steering = textMessage('steer-after-answer', 'change direction')
  711. const value = assembler([
  712. at(1, 'turn/start', { turn: 1 }),
  713. at(2, 'step/start', { turn: 1, step: 1 }),
  714. at(3, 'assistant/message', {
  715. turn: 1,
  716. step: 1,
  717. message: assistantMessage('assistant-before-steering', 'initial answer'),
  718. }, { surfaceOp: 'append' }),
  719. at(4, 'agent/inbox/spliced', {
  720. target: 'next-step',
  721. start: 0,
  722. inserted: [steering],
  723. }),
  724. at(5, 'agent/inbox/spliced', {
  725. target: 'next-step',
  726. start: 0,
  727. removedCount: 1,
  728. inserted: [],
  729. }),
  730. at(6, 'user/message', steering, { surfaceOp: 'append' }),
  731. at(7, 'step/end', { turn: 1, step: 1 }),
  732. at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  733. ])
  734. const current = snapshot(value)
  735. const steeringNode = node(current, 'steering')
  736. expect(steeringNode).toBeDefined()
  737. expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key)
  738. })
  739. it('classifies appended producer context from durable source metadata', () => {
  740. const value = assembler([
  741. at(1, 'user/message', {
  742. ...textMessage('skill-context', 'follow these instructions'),
  743. source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
  744. }, { surfaceOp: 'append' }),
  745. ])
  746. expect(node(snapshot(value), 'context')?.data).toMatchObject({
  747. kind: 'context',
  748. provenance: { role: 'inject', label: 'demo-skill' },
  749. form: 'instructions',
  750. })
  751. })
  752. it('materializes series starts and system changes but not same-series config or tool changes', () => {
  753. const tools = [{ name: 'read', description: 'Read', parameters: { type: 'object' } }]
  754. const expandedTools = [...tools, { name: 'write', description: 'Write', parameters: { type: 'object' } }]
  755. const value = assembler([
  756. at(1, 'request/header', {
  757. reason: 'initial',
  758. header: { config: { provider: 'fake', model: 'fake' }, system: '# Initial', tools },
  759. }),
  760. at(2, 'request/header', {
  761. reason: 'change',
  762. header: {
  763. config: { provider: 'fake', model: 'fake' },
  764. system: '# Initial',
  765. tools: expandedTools,
  766. },
  767. }),
  768. at(3, 'request/header', {
  769. reason: 'change',
  770. header: {
  771. config: { provider: 'fake', model: 'fake', maxTokens: 1_024 },
  772. system: '# Initial',
  773. tools: expandedTools,
  774. },
  775. }),
  776. at(4, 'request/header', {
  777. reason: 'change',
  778. startsSeries: true,
  779. header: {
  780. config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
  781. system: '# Initial',
  782. tools: expandedTools,
  783. },
  784. }),
  785. at(5, 'request/header', {
  786. reason: 'resume',
  787. header: {
  788. config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
  789. system: '# Initial',
  790. tools: expandedTools,
  791. },
  792. }),
  793. at(6, 'request/header', {
  794. reason: 'change',
  795. header: {
  796. config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
  797. system: '# Updated',
  798. tools: expandedTools,
  799. },
  800. }),
  801. ])
  802. const prompts = snapshot(value).nodes.values()
  803. .filter(candidate => candidate.kind === 'system-prompt')
  804. expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([
  805. { anchorSeq: 1, data: { text: '# Initial' } },
  806. { anchorSeq: 4, data: { text: '# Initial' } },
  807. { anchorSeq: 5, data: { text: '# Initial' } },
  808. { anchorSeq: 6, data: { text: '# Updated' } },
  809. ])
  810. const windowed = assembler([
  811. at(10, 'request/header', {
  812. reason: 'resume',
  813. header: { config: { provider: 'fake', model: 'fake' }, system: '# Resumed prompt' },
  814. }),
  815. ], true)
  816. const systemless = assembler([
  817. at(20, 'request/header', {
  818. reason: 'initial',
  819. header: { config: { provider: 'fake', model: 'fake' } },
  820. }),
  821. ])
  822. expect(node(snapshot(windowed), 'system-prompt')?.data).toEqual({ text: '# Resumed prompt' })
  823. expect(node(snapshot(systemless), 'system-prompt')).toBeUndefined()
  824. windowed.prepend([
  825. at(5, 'request/header', {
  826. reason: 'initial',
  827. header: { config: { provider: 'fake', model: 'fake' }, system: '# Original prompt' },
  828. }),
  829. ], false)
  830. windowed.flush()
  831. const restored = snapshot(windowed)
  832. const restoredPrompts = restored.order.flatMap((key) => {
  833. const candidate = restored.nodes.get(key)
  834. return candidate?.kind === 'system-prompt' ? [candidate] : []
  835. })
  836. expect(restoredPrompts.map(prompt => prompt.data)).toEqual([
  837. { text: '# Original prompt' },
  838. { text: '# Resumed prompt' },
  839. ])
  840. })
  841. it('orders the system field before the request messages while preserving message order', () => {
  842. const value = assembler([
  843. at(1, 'turn/start', { turn: 1 }),
  844. at(2, 'step/start', { turn: 1, step: 1 }),
  845. at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
  846. at(4, 'user/message', {
  847. ...textMessage('runtime-context', 'runtime facts'),
  848. source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt', form: 'snapshot' },
  849. }, { surfaceOp: 'append' }),
  850. at(5, 'request/header', {
  851. reason: 'initial',
  852. header: { config: { provider: 'fake', model: 'fake' }, system: '# System' },
  853. }),
  854. ])
  855. const current = snapshot(value)
  856. expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
  857. 'system-prompt',
  858. 'user',
  859. 'context',
  860. ])
  861. expect(node(current, 'system-prompt')?.anchorSeq).toBe(1)
  862. })
  863. it('keeps an append-only later user turn in the existing system-prompt series', () => {
  864. const value = assembler([
  865. at(1, 'turn/start', { turn: 1 }),
  866. at(2, 'step/start', { turn: 1, step: 1 }),
  867. at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
  868. at(4, 'request/header', {
  869. reason: 'initial',
  870. header: { config: { provider: 'fake', model: 'fake' }, system: '# System' },
  871. }),
  872. at(5, 'step/end', { turn: 1, step: 1 }),
  873. at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  874. at(7, 'turn/start', { turn: 2 }),
  875. at(8, 'step/start', { turn: 2, step: 1 }),
  876. at(9, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }),
  877. ])
  878. const current = snapshot(value)
  879. const ordered = current.order.flatMap((key) => {
  880. const candidate = current.nodes.get(key)
  881. return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
  882. })
  883. expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user'])
  884. })
  885. it('keeps windowed non-initial headers at their event until prepend supplies the preceding header', () => {
  886. const reasons = ['change', 'resume', 'series'] as const
  887. for (const reason of reasons) {
  888. const windowedSystem = reason === 'series' ? '# Original' : '# Windowed'
  889. const windowed = assembler([
  890. at(5, 'turn/start', { turn: 2 }),
  891. at(6, 'step/start', { turn: 2, step: 1 }),
  892. at(7, 'user/message', textMessage(`second-user-${reason}`, 'second'), { surfaceOp: 'append' }),
  893. at(8, 'request/header', {
  894. reason,
  895. header: { config: { provider: 'fake', model: 'fake' }, system: windowedSystem },
  896. }),
  897. ], true)
  898. expect(node(snapshot(windowed), 'system-prompt')?.anchorSeq).toBe(8)
  899. windowed.prepend([
  900. at(1, 'turn/start', { turn: 1 }),
  901. at(2, 'step/start', { turn: 1, step: 1 }),
  902. at(3, 'user/message', textMessage(`first-user-${reason}`, 'first'), { surfaceOp: 'append' }),
  903. at(4, 'request/header', {
  904. reason: 'initial',
  905. header: { config: { provider: 'fake', model: 'fake' }, system: '# Original' },
  906. }),
  907. ], false)
  908. windowed.flush()
  909. const restored = snapshot(windowed)
  910. const prompts = restored.order.flatMap((key) => {
  911. const candidate = restored.nodes.get(key)
  912. return candidate?.kind === 'system-prompt' ? [candidate] : []
  913. })
  914. expect(prompts.map(prompt => prompt.anchorSeq)).toEqual([1, 5])
  915. }
  916. })
  917. it('repeats an unchanged system prompt after a surface rewrite and before an explicit later series', () => {
  918. const value = assembler([
  919. at(1, 'turn/start', { turn: 1 }),
  920. at(2, 'step/start', { turn: 1, step: 1 }),
  921. at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
  922. at(4, 'request/header', {
  923. reason: 'initial',
  924. header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' },
  925. }),
  926. at(5, 'user/message', {
  927. ...textMessage('compacted', 'summary'),
  928. source: { kind: 'plugin', plugin: 'compact' },
  929. }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }),
  930. at(6, 'request/header', {
  931. reason: 'series',
  932. header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' },
  933. }),
  934. at(7, 'step/end', { turn: 1, step: 1 }),
  935. at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  936. at(9, 'turn/start', { turn: 2 }),
  937. at(10, 'step/start', { turn: 2, step: 1 }),
  938. at(11, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }),
  939. at(12, 'request/header', {
  940. reason: 'series',
  941. header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' },
  942. }),
  943. ])
  944. const current = snapshot(value)
  945. const ordered = current.order.flatMap((key) => {
  946. const candidate = current.nodes.get(key)
  947. return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
  948. })
  949. expect(ordered.map(candidate => candidate?.kind)).toEqual([
  950. 'system-prompt', 'user', 'system-prompt', 'system-prompt', 'user',
  951. ])
  952. expect(ordered.filter(candidate => candidate?.kind === 'system-prompt')
  953. .map(candidate => candidate?.anchorSeq)).toEqual([1, 6, 9])
  954. })
  955. it('associates each direct message with its immediately following session recall', () => {
  956. const value = assembler([
  957. at(1, 'user/message', textMessage('citing-research', '@Research notes what changed?'), { surfaceOp: 'append' }),
  958. at(2, 'user/message', {
  959. ...textMessage('research-context', 'snapshot'),
  960. source: {
  961. kind: 'session-reference',
  962. form: 'recall',
  963. version: 1,
  964. references: [{ sessionId: 'source-a', label: 'Research notes' }],
  965. },
  966. }, { surfaceOp: 'append' }),
  967. at(3, 'user/message', textMessage('citing-review', '@Review next'), { surfaceOp: 'append' }),
  968. at(4, 'user/message', {
  969. ...textMessage('review-context', 'snapshot'),
  970. source: {
  971. kind: 'session-reference',
  972. form: 'recall',
  973. version: 1,
  974. references: [{ sessionId: 'source-b', label: 'Review' }],
  975. },
  976. }, { surfaceOp: 'append' }),
  977. at(6, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }),
  978. ])
  979. const current = snapshot(value)
  980. const messages = [...current.nodes.values()]
  981. .filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
  982. const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user')
  983. expect(messages.map(candidate => candidate.kind)).toEqual(['user', 'context', 'user', 'context', 'user'])
  984. expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
  985. expect(users[1]?.data).toMatchObject({ referenceLabels: ['Review'] })
  986. expect(users[2]?.data).not.toHaveProperty('referenceLabels')
  987. })
  988. it('updates an already published direct node when its following recall arrives', () => {
  989. const value = assembler([
  990. at(1, 'user/message', textMessage('citing-user', '@Research notes what changed?'), { surfaceOp: 'append' }),
  991. ])
  992. const before = node(snapshot(value), 'user')
  993. expect(before?.data).not.toHaveProperty('referenceLabels')
  994. value.append(at(2, 'user/message', {
  995. ...textMessage('reference-context', 'snapshot'),
  996. source: {
  997. kind: 'session-reference',
  998. form: 'recall',
  999. version: 1,
  1000. references: [{ sessionId: 'source-a', label: 'Research notes' }],
  1001. },
  1002. }, { surfaceOp: 'append' }))
  1003. value.flush()
  1004. const current = snapshot(value)
  1005. const nodes = [...current.nodes.values()]
  1006. .filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
  1007. expect(nodes.map(candidate => candidate.kind)).toEqual(['user', 'context'])
  1008. expect(nodes[0]?.key).toBe(before?.key)
  1009. expect(nodes[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
  1010. expect(current.legacy.nodes[0]).toMatchObject({ referenceLabels: ['Research notes'] })
  1011. })
  1012. it('associates a claimed steering message with its following recall', () => {
  1013. const steering = textMessage('steering-reference', '@Research notes continue')
  1014. const value = assembler([
  1015. at(1, 'agent/inbox/spliced', {
  1016. target: 'next-step',
  1017. start: 0,
  1018. inserted: [steering],
  1019. }),
  1020. at(2, 'agent/inbox/spliced', {
  1021. target: 'next-step',
  1022. start: 0,
  1023. removedCount: 1,
  1024. inserted: [],
  1025. }),
  1026. at(3, 'user/message', steering, { surfaceOp: 'append' }),
  1027. at(4, 'user/message', {
  1028. ...textMessage('steering-reference-context', 'snapshot'),
  1029. source: {
  1030. kind: 'session-reference',
  1031. form: 'recall',
  1032. version: 1,
  1033. references: [{ sessionId: 'source-a', label: 'Research notes' }],
  1034. },
  1035. }, { surfaceOp: 'append' }),
  1036. ])
  1037. expect(node(snapshot(value), 'steering')?.data).toMatchObject({
  1038. messageId: 'steering-reference',
  1039. referenceLabels: ['Research notes'],
  1040. })
  1041. })
  1042. it('keeps replacement copies out of Chat business nodes', () => {
  1043. const value = assembler([
  1044. at(1, 'turn/start', { turn: 1 }),
  1045. at(2, 'step/start', { turn: 1, step: 1 }),
  1046. at(3, 'user/message', {
  1047. ...textMessage('replacement-user', 'model-only context'),
  1048. source: { kind: 'plugin', plugin: 'foreign' },
  1049. }, { surfaceOp: { op: 'replace', start: 1, end: 1 } }),
  1050. at(4, 'assistant/message', {
  1051. turn: 1,
  1052. step: 1,
  1053. message: assistantMessage('replacement-assistant', 'rewritten answer'),
  1054. }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }),
  1055. at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }),
  1056. at(6, 'tool/result', {
  1057. turn: 1,
  1058. step: 1,
  1059. message: toolResult('root', 'pruned result'),
  1060. }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }),
  1061. ])
  1062. const current = snapshot(value)
  1063. expect(node(current, 'user')).toBeUndefined()
  1064. expect(node(current, 'context')).toBeUndefined()
  1065. expect(node(current, 'assistant-step')).toBeUndefined()
  1066. expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind')
  1067. })
  1068. it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => {
  1069. const retry = assembler([
  1070. at(1, 'turn/start', { turn: 1 }),
  1071. at(2, 'step/start', { turn: 1, step: 1 }),
  1072. at(3, 'llm/retry', {
  1073. retryId: 'retry-1',
  1074. turn: 1,
  1075. step: 1,
  1076. provider: 'fake',
  1077. mode: 'normal',
  1078. policyKey: 'fake-normal',
  1079. retry: 1,
  1080. maxRetries: 2,
  1081. delayMs: 10,
  1082. failure: { code: 'TRANSPORT', message: 'first' },
  1083. }),
  1084. at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }),
  1085. at(5, 'llm/retry', {
  1086. retryId: 'retry-1',
  1087. turn: 1,
  1088. step: 1,
  1089. provider: 'fake',
  1090. mode: 'normal',
  1091. policyKey: 'fake-normal',
  1092. retry: 2,
  1093. maxRetries: 2,
  1094. delayMs: 20,
  1095. failure: { code: 'TRANSPORT', message: 'second' },
  1096. }),
  1097. at(6, 'step/end', { turn: 1, step: 1 }),
  1098. at(7, 'turn/end', {
  1099. turn: 1,
  1100. reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
  1101. }),
  1102. ])
  1103. const retryNode = node(snapshot(retry), 'model-retry')
  1104. const retryData = retryNode?.data as RetryChatData
  1105. expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled'])
  1106. expect(node(snapshot(retry), 'turn-error')?.data).toMatchObject({
  1107. kind: 'turn-error',
  1108. turn: 1,
  1109. message: 'failed',
  1110. code: 'TRANSPORT',
  1111. })
  1112. const compactions = assembler([
  1113. at(10, 'command/run', {
  1114. commandId: 'command-1',
  1115. name: 'compact',
  1116. source: { kind: 'user' },
  1117. }),
  1118. at(11, 'compaction/start', {
  1119. compactionId: 'manual-1',
  1120. sourceCommandId: 'command-1',
  1121. turn: null,
  1122. }),
  1123. at(12, 'compaction/summary', {
  1124. compactionId: 'manual-1',
  1125. sourceCommandId: 'command-1',
  1126. summary: [{ type: 'text', text: 'manual summary' }],
  1127. shadowedSeqs: [1, 2],
  1128. shadowedTokenCount: 100,
  1129. }),
  1130. at(13, 'user/message', {
  1131. ...textMessage('manual-checkpoint', 'checkpoint'),
  1132. source: {
  1133. kind: 'plugin',
  1134. plugin: 'compact',
  1135. compactionId: 'manual-1',
  1136. sourceCommandId: 'command-1',
  1137. },
  1138. }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
  1139. at(14, 'compaction/end', {
  1140. compactionId: 'manual-1',
  1141. sourceCommandId: 'command-1',
  1142. turn: null,
  1143. }),
  1144. at(15, 'command/done', {
  1145. commandId: 'command-1',
  1146. kind: 'success',
  1147. sourceEventSeq: 12,
  1148. }),
  1149. at(20, 'compaction/start', { compactionId: 'automatic-1', turn: null }),
  1150. at(21, 'compaction/summary', {
  1151. compactionId: 'automatic-1',
  1152. summary: [{ type: 'text', text: 'automatic summary' }],
  1153. shadowedSeqs: [3, 4],
  1154. shadowedTokenCount: 200,
  1155. }),
  1156. at(22, 'user/message', {
  1157. ...textMessage('automatic-checkpoint', 'checkpoint'),
  1158. source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' },
  1159. }, { surfaceOp: { op: 'replace', start: 3, end: 4 } }),
  1160. at(23, 'compaction/end', { compactionId: 'automatic-1', turn: null }),
  1161. ])
  1162. const manual = node(snapshot(compactions), 'manual-compaction')
  1163. expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({
  1164. summary: 'manual summary',
  1165. summaryEventSeq: 12,
  1166. })
  1167. const automatic = node(snapshot(compactions), 'compaction')
  1168. expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 })
  1169. expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1)
  1170. })
  1171. it('fills a landed compaction marker when an older page supplies its summary', () => {
  1172. const value = assembler([
  1173. at(13, 'user/message', {
  1174. ...textMessage('checkpoint', 'checkpoint'),
  1175. source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' },
  1176. }, { surfaceOp: { op: 'replace', start: 1, end: 8 } }),
  1177. ], true)
  1178. const before = node(snapshot(value), 'compaction')
  1179. expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null })
  1180. value.prepend([
  1181. at(9, 'compaction/start', { compactionId: 'compact-1', turn: null }),
  1182. at(10, 'compaction/summary', {
  1183. compactionId: 'compact-1',
  1184. summary: [
  1185. { type: 'text', text: 'older ' },
  1186. { type: 'image', data: 'ignored' },
  1187. { type: 'text', text: 'summary' },
  1188. ],
  1189. shadowedSeqs: [1, 2, 3],
  1190. shadowedTokenCount: 42,
  1191. }),
  1192. ], false)
  1193. value.flush()
  1194. const after = node(snapshot(value), 'compaction')
  1195. expect(after?.key).toBe(before?.key)
  1196. expect(after?.data).toMatchObject({
  1197. summary: 'older summary',
  1198. summaryEventSeq: 10,
  1199. shadowedItemCount: 3,
  1200. shadowedTokenCount: 42,
  1201. })
  1202. })
  1203. it('renders a historical compaction when its start remains outside the loaded window', () => {
  1204. const value = assembler([
  1205. at(10, 'compaction/summary', {
  1206. compactionId: 'compact-windowed',
  1207. summary: [{ type: 'text', text: 'loaded summary' }],
  1208. shadowedSeqs: [1, 2, 3],
  1209. shadowedTokenCount: 42,
  1210. }),
  1211. at(11, 'user/message', {
  1212. ...textMessage('checkpoint-windowed', 'checkpoint'),
  1213. source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' },
  1214. }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
  1215. ], true)
  1216. expect(node(snapshot(value), 'compaction')?.data).toMatchObject({
  1217. summary: 'loaded summary',
  1218. summaryEventSeq: 10,
  1219. shadowedItemCount: 3,
  1220. shadowedTokenCount: 42,
  1221. })
  1222. })
  1223. it('ignores legacy compaction transactions without correlation ids', () => {
  1224. const value = assembler([
  1225. at(10, 'compaction/start', { turn: null }),
  1226. at(11, 'compaction/end', { turn: null, error: 'This operation was aborted' }),
  1227. at(20, 'compaction/start', { turn: null }),
  1228. at(21, 'compaction/summary', {
  1229. summary: [{ type: 'text', text: 'legacy summary' }],
  1230. shadowedSeqs: [1, 2, 3],
  1231. shadowedTokenCount: 42,
  1232. }),
  1233. at(22, 'user/message', {
  1234. ...textMessage('legacy-checkpoint', 'checkpoint'),
  1235. source: { kind: 'plugin', plugin: 'compact' },
  1236. }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
  1237. at(23, 'compaction/end', { turn: null }),
  1238. ], true)
  1239. expect(node(snapshot(value), 'compaction')).toBeUndefined()
  1240. })
  1241. it('ignores legacy retry and code-dispatch events without correlation ids', () => {
  1242. const value = assembler([
  1243. at(10, 'llm/retry', {
  1244. turn: 1,
  1245. step: 1,
  1246. provider: 'fake',
  1247. mode: 'normal',
  1248. policyKey: 'fake-normal',
  1249. retry: 1,
  1250. maxRetries: 2,
  1251. delayMs: 10,
  1252. failure: { code: 'TRANSPORT', message: 'first legacy retry' },
  1253. }),
  1254. at(11, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
  1255. at(20, 'llm/retry', {
  1256. turn: 2,
  1257. step: 1,
  1258. provider: 'fake',
  1259. mode: 'normal',
  1260. policyKey: 'fake-normal',
  1261. retry: 1,
  1262. maxRetries: 2,
  1263. delayMs: 10,
  1264. failure: { code: 'TRANSPORT', message: 'second legacy retry' },
  1265. }),
  1266. at(30, 'tool/code-dispatch-start', {
  1267. parentCallId: 'root',
  1268. subCallId: 'child',
  1269. name: 'legacy-subcall',
  1270. arguments: {},
  1271. }),
  1272. at(31, 'tool/code-dispatch', {
  1273. parentCallId: 'root',
  1274. subCallId: 'child',
  1275. name: 'legacy-subcall',
  1276. arguments: {},
  1277. content: [],
  1278. }),
  1279. ], true)
  1280. expect(node(snapshot(value), 'model-retry')).toBeUndefined()
  1281. expect(node(snapshot(value), 'tool-call')).toBeUndefined()
  1282. })
  1283. it('renders the exhausted-retry turn error in a partial tail window and after prepending the chain', () => {
  1284. const value = assembler([
  1285. at(5, 'llm/retry', {
  1286. retryId: 'retry-paged',
  1287. turn: 1,
  1288. step: 1,
  1289. provider: 'fake',
  1290. mode: 'normal',
  1291. policyKey: 'fake-normal',
  1292. retry: 2,
  1293. maxRetries: 2,
  1294. delayMs: 20,
  1295. failure: { code: 'TRANSPORT', message: 'second' },
  1296. }),
  1297. at(6, 'step/end', { turn: 1, step: 1 }),
  1298. at(7, 'turn/end', {
  1299. turn: 1,
  1300. reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
  1301. }),
  1302. ], true)
  1303. expect(node(snapshot(value), 'model-retry')).toBeUndefined()
  1304. expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({
  1305. kind: 'turn-error',
  1306. seq: 7,
  1307. turn: 1,
  1308. message: 'failed',
  1309. code: 'TRANSPORT',
  1310. })
  1311. value.prepend([
  1312. at(1, 'turn/start', { turn: 1 }),
  1313. at(2, 'step/start', { turn: 1, step: 1 }),
  1314. at(3, 'llm/retry', {
  1315. retryId: 'retry-paged',
  1316. turn: 1,
  1317. step: 1,
  1318. provider: 'fake',
  1319. mode: 'normal',
  1320. policyKey: 'fake-normal',
  1321. retry: 1,
  1322. maxRetries: 2,
  1323. delayMs: 10,
  1324. failure: { code: 'TRANSPORT', message: 'first' },
  1325. }),
  1326. at(4, 'llm/retry-started', {
  1327. retryId: 'retry-paged', turn: 1, step: 1, retry: 1,
  1328. }),
  1329. ], false)
  1330. value.flush()
  1331. const retry = node(snapshot(value), 'model-retry')
  1332. expect((retry?.data as RetryChatData).attempts).toHaveLength(2)
  1333. expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({
  1334. kind: 'turn-error',
  1335. seq: 7,
  1336. turn: 1,
  1337. message: 'failed',
  1338. code: 'TRANSPORT',
  1339. })
  1340. })
  1341. it('materializes a max-tokens notice and keeps completed and error turns clean', () => {
  1342. const value = assembler([
  1343. at(1, 'turn/start', { turn: 1 }),
  1344. at(2, 'step/start', { turn: 1, step: 1 }),
  1345. at(3, 'assistant/message', {
  1346. turn: 1, step: 1, message: assistantMessage('a1', 'truncated answer'),
  1347. }, { surfaceOp: 'append' }),
  1348. at(4, 'step/end', { turn: 1, step: 1 }),
  1349. at(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } }),
  1350. ])
  1351. const notice = node(snapshot(value), 'turn-max-tokens')
  1352. expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 })
  1353. expect(node(snapshot(value), 'turn-error')).toBeUndefined()
  1354. // The tail stays the turn's last node so its branch action survives; the
  1355. // notice slots between the truncated closing Assistant and the tail.
  1356. const tail = node(snapshot(value), 'turn-tail')
  1357. expect(notice?.anchorSeq).toBeLessThan(tail?.anchorSeq ?? Number.NEGATIVE_INFINITY)
  1358. expect(notice?.anchorSeq).toBeGreaterThan(3)
  1359. const completed = assembler([
  1360. at(1, 'turn/start', { turn: 1 }),
  1361. at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  1362. ])
  1363. expect(node(snapshot(completed), 'turn-max-tokens')).toBeUndefined()
  1364. const failed = assembler([
  1365. at(1, 'turn/start', { turn: 1 }),
  1366. at(2, 'turn/end', {
  1367. turn: 1,
  1368. reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
  1369. }),
  1370. ])
  1371. expect(node(snapshot(failed), 'turn-max-tokens')).toBeUndefined()
  1372. expect(node(snapshot(failed), 'turn-error')).toBeDefined()
  1373. })
  1374. it('keeps the max-tokens notice when the window starts after the owning turn/start', () => {
  1375. const value = assembler([
  1376. at(9, 'turn/end', { turn: 3, reason: { kind: 'max-tokens' } }),
  1377. ], true)
  1378. const notice = node(snapshot(value), 'turn-max-tokens')
  1379. expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 9, turn: 3 })
  1380. })
  1381. it('pins the max-tokens Definition edges the engine cannot reach', () => {
  1382. // The engine only hands start the single matched turn/end and never emits
  1383. // update Matches for this kind; these direct calls pin the declared
  1384. // behavior of both required Definition members anyway.
  1385. const match = (seq: number, type: string, data: unknown) => ({
  1386. event: { seq, time: seq * 1_000, type, data },
  1387. role: 'start',
  1388. location: undefined,
  1389. }) as unknown as Parameters<typeof turnMaxTokensDefinition.start>[1]
  1390. const context = (state: unknown, matches: unknown[] = []) => ({
  1391. key: 'k', kind: 'turn-max-tokens', id: '1', matches, start: undefined, state, current: new Map(),
  1392. }) as unknown as Parameters<NonNullable<typeof turnMaxTokensDefinition.buildViewNode>>[0]
  1393. const reader = { previous: () => undefined }
  1394. expect(() => turnMaxTokensDefinition.start(context(undefined), match(1, 'turn/start', { turn: 1 }), reader))
  1395. .toThrow('turn-max-tokens start requires a max-tokens turn/end')
  1396. const state = { turn: 1, seq: 5, time: 5_000 }
  1397. expect(turnMaxTokensDefinition.update(
  1398. context(state) as Parameters<typeof turnMaxTokensDefinition.update>[0],
  1399. match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  1400. )).toBe(state)
  1401. expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull()
  1402. })
  1403. it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
  1404. const value = assembler([
  1405. at(12, 'tool/code-dispatch-start', {
  1406. rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
  1407. }),
  1408. at(13, 'tool/code-dispatch', {
  1409. rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
  1410. isError: false, content: [{ type: 'text', text: 'child result' }],
  1411. }),
  1412. at(14, 'tool/result', {
  1413. turn: 1,
  1414. step: 1,
  1415. message: toolResult('root', 'root result'),
  1416. }, { surfaceOp: 'append' }),
  1417. at(20, 'compaction/summary', {
  1418. compactionId: 'manual-1',
  1419. sourceCommandId: 'command-1',
  1420. summary: [{ type: 'text', text: 'manual summary' }],
  1421. shadowedSeqs: [1, 2],
  1422. shadowedTokenCount: 100,
  1423. }),
  1424. at(21, 'user/message', {
  1425. ...textMessage('manual-checkpoint', 'checkpoint'),
  1426. source: {
  1427. kind: 'plugin',
  1428. plugin: 'compact',
  1429. compactionId: 'manual-1',
  1430. sourceCommandId: 'command-1',
  1431. },
  1432. }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
  1433. at(22, 'command/done', {
  1434. commandId: 'command-1',
  1435. kind: 'success',
  1436. sourceEventSeq: 20,
  1437. }),
  1438. ], true)
  1439. const tool = node(snapshot(value), 'tool-call')
  1440. const root = (tool?.data as ToolChatData).root
  1441. expect(root.subCalls).toHaveLength(1)
  1442. expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' })
  1443. const manual = node(snapshot(value), 'manual-compaction')
  1444. expect((manual?.data as ManualCompactionChatData)).toMatchObject({
  1445. command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } },
  1446. compaction: { summary: 'manual summary', summaryEventSeq: 20 },
  1447. })
  1448. })
  1449. })