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

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017
  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 { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
  19. import { decodeTurnProcess } from '../src/client/contract/turn-process.ts'
  20. import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
  21. import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
  22. import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
  23. import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
  24. import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
  25. import { nextStepInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
  26. import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
  27. import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
  28. import { requestPromptDefinition } from '../src/client/conversation-nodes/request-prompt.ts'
  29. import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
  30. import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
  31. import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
  32. import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
  33. import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
  34. import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts'
  35. import type {
  36. AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
  37. } from '../src/client/contract/chat-nodes.ts'
  38. const DEFINITIONS: readonly ConversationNodeDefinition[] = [
  39. nextStepInboxDefinition,
  40. messageDefinition,
  41. requestPromptDefinition(inspectRequestPrompt),
  42. assistantDefinition,
  43. turnProcessDefinition,
  44. toolDefinition,
  45. commandDefinition,
  46. compactionDefinition,
  47. retryDefinition,
  48. turnErrorDefinition,
  49. turnMaxTokensDefinition,
  50. turnTailDefinition,
  51. ]
  52. class TestEventDefinitions {
  53. entries(): readonly ConversationNodeDefinition[] {
  54. return DEFINITIONS
  55. }
  56. fallbackEntry(): ConversationNodeDefinition {
  57. return unknownFallbackDefinition
  58. }
  59. }
  60. class TestViewDefinitions {
  61. entries(): readonly ConversationViewDefinition[] {
  62. return [chatViewDefinition]
  63. }
  64. }
  65. function at(
  66. seq: number,
  67. type: string,
  68. data: unknown,
  69. extra: Record<string, unknown> = {},
  70. ): SessionLiveEventEntry {
  71. return {
  72. type: 'event',
  73. event: {
  74. seq,
  75. time: 1_700_000_000_000 + seq,
  76. type,
  77. data,
  78. ...extra,
  79. } as unknown as SessionEvent,
  80. }
  81. }
  82. function chunkEntry(row: ChunkRow): SessionEventLikeEntry {
  83. return {
  84. type: 'chunks',
  85. event: {
  86. type: `chunkrow/${row.type}`,
  87. seq: row.seq0,
  88. time: row.time0,
  89. data: row.data,
  90. } as ChunkRowEvent,
  91. }
  92. }
  93. function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
  94. return packChunkRuns(entries.map(entry => entry.event)).map((record) => {
  95. return isChunkRow(record) ? chunkEntry(record) : { type: 'event', event: record }
  96. })
  97. }
  98. function assembler(entries: readonly SessionEventLikeEntry[] = [], hasMore = false): ConversationNodeAssembler {
  99. const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
  100. value.replaceWindow(entries, hasMore)
  101. value.activateTarget('chat')
  102. return value
  103. }
  104. function snapshot(value: ConversationNodeAssembler): ChatSnapshot {
  105. const current = value.snapshot('chat') as ChatSnapshot | undefined
  106. if (current === undefined) throw new Error('chat view was not registered')
  107. return current
  108. }
  109. function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
  110. return value.nodes.values().find(candidate => candidate.kind === kind)
  111. }
  112. function textMessage(id: string, text: string) {
  113. return {
  114. id,
  115. role: 'user',
  116. content: [{ type: 'text', text }],
  117. source: { kind: 'user' },
  118. }
  119. }
  120. function assistantMessage(id: string, text: string) {
  121. return {
  122. id,
  123. role: 'assistant',
  124. content: [{ type: 'text', text }],
  125. source: { kind: 'model', provider: 'fake', model: 'fake' },
  126. }
  127. }
  128. function toolResult(callId: string, text: string, isError = false) {
  129. return {
  130. id: `result-${callId}`,
  131. role: 'user',
  132. source: { kind: 'tool', callId },
  133. content: [{
  134. type: 'tool-result',
  135. toolCallId: callId,
  136. content: [{ type: 'text', text }],
  137. isError,
  138. }],
  139. }
  140. }
  141. describe('built-in conversation node Definitions', () => {
  142. it('rejects an unrelated event passed directly to the request-prompt start', () => {
  143. const input = at(1, 'turn/start', { turn: 1 })
  144. const invalidStart = {
  145. ...input,
  146. role: 'start' as const,
  147. location: { kind: 'session' as const },
  148. }
  149. expect(() => requestPromptDefinition(inspectRequestPrompt).start({} as never, invalidStart, {} as never))
  150. .toThrow('request-prompt start requires request/header')
  151. })
  152. it('keeps ordinary command-only history inactive for the Conversation shell', () => {
  153. const value = assembler([
  154. at(1, 'command/run', {
  155. commandId: 'command-1',
  156. name: 'help',
  157. source: { kind: 'user' },
  158. }),
  159. at(2, 'command/done', {
  160. commandId: 'command-1',
  161. kind: 'success',
  162. }),
  163. ])
  164. const current = snapshot(value)
  165. expect(current.order).toHaveLength(1)
  166. expect(current.nodes.get(current.order[0] ?? '')?.kind).toBe('command')
  167. expect(chatViewDefinition.isActive?.(current)).toBe(false)
  168. })
  169. it('keeps the Turn rail projection current when a chunk updates one node in place', () => {
  170. const value = assembler([
  171. at(1, 'turn/start', { turn: 1 }),
  172. at(2, 'user/message', textMessage('user-1', 'navigate here'), { surfaceOp: 'append' }),
  173. at(3, 'step/start', { turn: 1, step: 1 }),
  174. at(4, 'assistant/chunk', {
  175. turn: 1,
  176. step: 1,
  177. chunk: { type: 'text-delta', index: 0, text: 'first' },
  178. }),
  179. ])
  180. const opening = snapshot(value).navigation.items()
  181. expect(opening).toHaveLength(1)
  182. expect(opening[0]?.turn).toBe(1)
  183. expect(opening[0]?.prompt).toBe('navigate here')
  184. expect(opening[0]?.response).toBe('first')
  185. // Content-only upsert: the node keeps its key, so the rail's preview has to
  186. // follow the in-place update rather than the last structural publication.
  187. value.append(at(5, 'assistant/chunk', {
  188. turn: 1,
  189. step: 1,
  190. chunk: { type: 'text-delta', index: 0, text: ' and more' },
  191. }))
  192. value.flush()
  193. const streamed = snapshot(value).navigation.items()
  194. expect(streamed[0]?.response).toBe('first and more')
  195. expect(streamed).not.toBe(opening)
  196. })
  197. it('bounds each rail preview at its card budget instead of copying the whole transcript', () => {
  198. const long = 'x'.repeat(400)
  199. const value = assembler([
  200. at(1, 'turn/start', { turn: 1 }),
  201. at(2, 'user/message', textMessage('user-1', long), { surfaceOp: 'append' }),
  202. ])
  203. const items = snapshot(value).navigation.items()
  204. // One clipped prompt line: 49 characters plus the trailing ellipsis.
  205. expect(items[0]?.prompt.length).toBe(50)
  206. expect(items[0]?.prompt.endsWith('…')).toBe(true)
  207. })
  208. it('classifies reply content separately from reasoning and Tool protocol blocks', () => {
  209. expect(hasAssistantReplyContent([{ kind: 'text', text: ' ' }])).toBe(false)
  210. expect(hasAssistantReplyContent([{ kind: 'reasoning', text: 'thinking' }])).toBe(false)
  211. expect(hasAssistantReplyContent([{ kind: 'tool-call', callId: 'c', name: 'read', argsRaw: '{}' }])).toBe(false)
  212. expect(hasAssistantReplyContent([{ kind: 'text', text: 'answer' }])).toBe(true)
  213. expect(hasAssistantReplyContent([{ kind: 'image', attachment: {} as never }])).toBe(true)
  214. expect(hasAssistantReplyContent([{ kind: 'other', block: { type: 'future' } }])).toBe(true)
  215. })
  216. it('projects one reversible process window before the finalized answer', () => {
  217. const value = assembler([
  218. at(1, 'turn/start', { turn: 1 }),
  219. at(2, 'step/start', { turn: 1, step: 1 }),
  220. at(3, 'user/message', {
  221. ...textMessage('context-1', 'workspace context'),
  222. turn: 1,
  223. step: 1,
  224. source: { kind: 'plugin', plugin: 'context' },
  225. }, { surfaceOp: 'append' }),
  226. at(4, 'assistant/chunk', {
  227. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
  228. }),
  229. at(5, 'assistant/chunk', {
  230. turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'checking' },
  231. }),
  232. at(6, 'assistant/chunk', {
  233. turn: 1,
  234. step: 1,
  235. chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'read', argumentsDelta: '{}' },
  236. }),
  237. ])
  238. const process = () => {
  239. const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
  240. return signature === undefined ? undefined : decodeTurnProcess(signature)
  241. }
  242. expect(process()).toMatchObject({ processStartSeq: 4, answerAnchorSeq: null, answerStep: null })
  243. expect(node(snapshot(value), 'turn-process')?.data).toMatchObject({ answerAnchorSeq: null })
  244. value.append(at(7, 'tool/call', {
  245. turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}',
  246. }))
  247. value.append(at(8, 'tool/result', {
  248. turn: 1, step: 1, message: toolResult('call-1', 'done'),
  249. }, { surfaceOp: 'append' }))
  250. value.append(at(9, 'step/end', { turn: 1, step: 1 }))
  251. value.append(at(10, 'step/start', { turn: 1, step: 2 }))
  252. value.append(at(11, 'assistant/chunk', {
  253. turn: 1, step: 2, chunk: { type: 'reasoning-delta', index: 0, text: 'final thinking' },
  254. }))
  255. value.append(at(12, 'assistant/chunk', {
  256. turn: 1, step: 2, chunk: { type: 'text-delta', index: 1, text: 'final reply' },
  257. }))
  258. value.flush()
  259. expect(process()).toMatchObject({
  260. processStartSeq: 4,
  261. answerAnchorSeq: null,
  262. answerStep: null,
  263. inlineReasoning: false,
  264. })
  265. value.append(at(13, 'llm/retry', {
  266. retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal',
  267. policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
  268. failure: { code: 'TRANSPORT', message: 'temporary' },
  269. }))
  270. value.flush()
  271. expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null })
  272. value.append(at(14, 'assistant/chunk', {
  273. turn: 1,
  274. step: 2,
  275. chunk: { type: 'text-delta', index: 0, text: 'replacement reply' },
  276. }))
  277. value.flush()
  278. expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null })
  279. value.append(at(15, 'step/end', { turn: 1, step: 2 }))
  280. value.append(at(16, 'turn/end', {
  281. turn: 1,
  282. reason: { kind: 'aborted', reason: { kind: 'user' } },
  283. }))
  284. value.flush()
  285. expect(process()).toMatchObject({ answerAnchorSeq: 14.1, answerStep: 2 })
  286. const recovered = assembler([
  287. at(20, 'turn/start', { turn: 2 }),
  288. at(21, 'step/start', { turn: 2, step: 1 }),
  289. at(22, 'assistant/message', {
  290. turn: 2, step: 1, message: assistantMessage('recovered-1', 'settled reply'),
  291. }, { surfaceOp: 'append' }),
  292. at(23, 'step/end', { turn: 2, step: 1 }),
  293. at(24, 'step/start', { turn: 2, step: 2 }),
  294. at(25, 'assistant/chunk', {
  295. turn: 2, step: 2, chunk: { type: 'text-delta', index: 0, text: 'crash partial' },
  296. }),
  297. at(26, 'turn/end', { turn: 2, reason: { kind: 'interrupted' } }),
  298. ])
  299. const recoveredSignature = snapshot(recovered).timeline.turns.get(2)?.data.get('turn-process')
  300. expect(recoveredSignature === undefined ? undefined : decodeTurnProcess(recoveredSignature))
  301. .toMatchObject({ answerStep: 2, answerAnchorSeq: 25.1 })
  302. const partialWindow = assembler([
  303. at(30, 'assistant/chunk', {
  304. turn: 3, step: 4, chunk: { type: 'text-delta', index: 0, text: 'loaded tail' },
  305. }),
  306. at(31, 'step/end', { turn: 3, step: 4 }),
  307. ], true)
  308. const partialSignature = snapshot(partialWindow).timeline.turns.get(3)?.data.get('turn-process')
  309. expect(partialSignature === undefined ? undefined : decodeTurnProcess(partialSignature))
  310. .toMatchObject({ processStartSeq: 30.1, answerAnchorSeq: 30.1, answerStep: 4 })
  311. })
  312. it('counts Assistant messages, Tool calls, and subagent delegations per Turn', () => {
  313. const value = assembler([
  314. at(1, 'turn/start', { turn: 1 }),
  315. at(2, 'step/start', { turn: 1, step: 1 }),
  316. at(3, 'assistant/message', {
  317. turn: 1, step: 1, message: assistantMessage('message-1', 'checking'),
  318. }, { surfaceOp: 'append' }),
  319. at(4, 'tool/call', {
  320. turn: 1, step: 1, callId: 'call-read', name: 'read', arguments: '{}',
  321. }),
  322. at(5, 'tool/result', {
  323. turn: 1, step: 1, message: toolResult('call-read', 'read done'),
  324. }, { surfaceOp: 'append' }),
  325. at(6, 'tool/call', {
  326. turn: 1, step: 1, callId: 'call-subagent', name: 'subagent_fork', arguments: '{}',
  327. }),
  328. at(7, 'tool/result', {
  329. turn: 1, step: 1, message: toolResult('call-subagent', 'delegation done'),
  330. }, { surfaceOp: 'append' }),
  331. at(8, 'step/end', { turn: 1, step: 1 }),
  332. at(9, 'step/start', { turn: 1, step: 2 }),
  333. at(10, 'assistant/message', {
  334. turn: 1, step: 2, message: assistantMessage('message-2', 'final answer'),
  335. }, { surfaceOp: 'append' }),
  336. at(11, 'step/end', { turn: 1, step: 2 }),
  337. at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  338. ])
  339. const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
  340. expect(signature === undefined ? undefined : decodeTurnProcess(signature)).toMatchObject({
  341. messageCount: 1,
  342. toolCallCount: 1,
  343. subagentCount: 1,
  344. })
  345. })
  346. it('orders the opening User before its process control and later steering', () => {
  347. const steering = textMessage('steer-1', 'change direction')
  348. const value = assembler([
  349. at(1, 'turn/start', { turn: 1 }),
  350. at(2, 'user/message', {
  351. ...textMessage('context-1', 'runtime context'),
  352. source: { kind: 'plugin', plugin: 'context' },
  353. }, { surfaceOp: 'append' }),
  354. at(3, 'user/message', textMessage('user-1', 'question'), { surfaceOp: 'append' }),
  355. at(4, 'step/start', { turn: 1, step: 1 }),
  356. ])
  357. const opening = snapshot(value)
  358. expect(opening.order.map(key => opening.nodes.get(key)?.kind)).toEqual([
  359. 'user', 'context',
  360. ])
  361. value.append(at(5, 'assistant/chunk', {
  362. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
  363. }))
  364. value.flush()
  365. const running = snapshot(value)
  366. expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([
  367. 'user', 'turn-process', 'context', 'assistant-step',
  368. ])
  369. value.append(at(6, 'agent/inbox/spliced', {
  370. target: 'next-step', start: 0, inserted: [steering],
  371. }))
  372. value.append(at(7, 'agent/inbox/spliced', {
  373. target: 'next-step', start: 0, removedCount: 1, inserted: [],
  374. }))
  375. value.append(at(8, 'user/message', steering, { surfaceOp: 'append' }))
  376. value.append(at(9, 'step/end', { turn: 1, step: 1 }))
  377. value.append(at(10, 'step/start', { turn: 1, step: 2 }))
  378. value.append(at(11, 'assistant/message', {
  379. turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
  380. }, { surfaceOp: 'append' }))
  381. value.append(at(12, 'step/end', { turn: 1, step: 2 }))
  382. value.append(at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
  383. value.flush()
  384. const current = snapshot(value)
  385. expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
  386. 'user', 'turn-process', 'context', 'steering', 'assistant-step', 'assistant-step', 'turn-tail',
  387. ])
  388. })
  389. it('replays pending splice chains and scopes steering to the current claim', () => {
  390. const first = textMessage('claim-first', 'first')
  391. const second = textMessage('claim-second', 'second')
  392. const canceled = textMessage('claim-canceled', 'canceled')
  393. const requeued = textMessage('claim-requeued', 'requeued')
  394. const later = textMessage('claim-later', 'later')
  395. const current = snapshot(assembler([
  396. at(1, 'agent/inbox/spliced', {
  397. target: 'next-step', start: 0, inserted: [first],
  398. }),
  399. at(2, 'agent/inbox/spliced', {
  400. target: 'next-step', start: 1, inserted: [canceled],
  401. }),
  402. at(3, 'agent/inbox/spliced', {
  403. target: 'next-step', start: 1, inserted: [second],
  404. }),
  405. at(4, 'agent/inbox/spliced', {
  406. target: 'next-step', start: 2, removedCount: 1, inserted: [], outcome: 'canceled',
  407. }),
  408. at(5, 'agent/inbox/spliced', {
  409. target: 'next-step', start: 0, removedCount: 2, inserted: [],
  410. }),
  411. at(6, 'user/message', first, { surfaceOp: 'append' }),
  412. at(7, 'user/message', second, { surfaceOp: 'append' }),
  413. at(8, 'agent/inbox/spliced', {
  414. target: 'next-step', start: 0, inserted: [requeued],
  415. }),
  416. at(9, 'agent/inbox/spliced', {
  417. target: 'next-step', start: 0, removedCount: 1, inserted: [],
  418. }),
  419. at(10, 'agent/inbox/spliced', {
  420. target: 'next-step', start: 0, inserted: [requeued],
  421. }),
  422. at(11, 'user/message', requeued, { surfaceOp: 'append' }),
  423. at(12, 'user/message', canceled, { surfaceOp: 'append' }),
  424. at(13, 'agent/inbox/spliced', {
  425. target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
  426. }),
  427. at(14, 'agent/inbox/spliced', {
  428. target: 'next-step', start: 0, inserted: [later],
  429. }),
  430. at(15, 'agent/inbox/spliced', {
  431. target: 'next-step', start: 0, removedCount: 1, inserted: [],
  432. }),
  433. at(16, 'user/message', later, { surfaceOp: 'append' }),
  434. ]))
  435. expect(current.order.map(key => current.nodes.get(key)).filter(node =>
  436. node?.kind === 'user' || node?.kind === 'steering')).toMatchObject([
  437. { kind: 'steering', data: { seq: 6 } },
  438. { kind: 'steering', data: { seq: 7 } },
  439. { kind: 'user', data: { seq: 11 } },
  440. { kind: 'user', data: { seq: 12 } },
  441. { kind: 'steering', data: { seq: 16 } },
  442. ])
  443. })
  444. it('orders a command-started Turn first steering before its process control', () => {
  445. const steering = textMessage('command-task', 'plan this change')
  446. const value = assembler([
  447. at(1, 'turn/start', { turn: 1 }),
  448. at(2, 'agent/inbox/spliced', {
  449. target: 'next-step', start: 0, inserted: [steering],
  450. }),
  451. at(3, 'agent/inbox/spliced', {
  452. target: 'next-step', start: 0, removedCount: 1, inserted: [],
  453. }),
  454. at(4, 'user/message', steering, { surfaceOp: 'append' }),
  455. at(5, 'step/start', { turn: 1, step: 1 }),
  456. at(6, 'assistant/chunk', {
  457. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
  458. }),
  459. at(7, 'step/end', { turn: 1, step: 1 }),
  460. at(8, 'step/start', { turn: 1, step: 2 }),
  461. at(9, 'assistant/message', {
  462. turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
  463. }, { surfaceOp: 'append' }),
  464. at(10, 'step/end', { turn: 1, step: 2 }),
  465. at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  466. ])
  467. const current = snapshot(value)
  468. expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
  469. 'steering', 'turn-process', 'assistant-step', 'assistant-step', 'turn-tail',
  470. ])
  471. })
  472. it('keeps a first human message after process evidence at its event position', () => {
  473. const steering = textMessage('late-steering', 'change direction')
  474. const value = assembler([
  475. at(1, 'turn/start', { turn: 1 }),
  476. at(2, 'step/start', { turn: 1, step: 1 }),
  477. at(3, 'tool/call', {
  478. turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}',
  479. }),
  480. at(4, 'tool/result', {
  481. turn: 1, step: 1, message: toolResult('call-1', 'done'),
  482. }, { surfaceOp: 'append' }),
  483. at(5, 'agent/inbox/spliced', {
  484. target: 'next-step', start: 0, inserted: [steering],
  485. }),
  486. at(6, 'agent/inbox/spliced', {
  487. target: 'next-step', start: 0, removedCount: 1, inserted: [],
  488. }),
  489. at(7, 'user/message', steering, { surfaceOp: 'append' }),
  490. at(8, 'step/end', { turn: 1, step: 1 }),
  491. at(9, 'step/start', { turn: 1, step: 2 }),
  492. at(10, 'assistant/message', {
  493. turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
  494. }, { surfaceOp: 'append' }),
  495. at(11, 'step/end', { turn: 1, step: 2 }),
  496. at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  497. ])
  498. const current = snapshot(value)
  499. expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
  500. 'turn-process', 'tool-call', 'steering', 'assistant-step', 'turn-tail',
  501. ])
  502. })
  503. it('keeps Process before pre-User Context as answer eligibility changes', () => {
  504. const value = assembler([
  505. at(1, 'turn/start', { turn: 1 }),
  506. at(2, 'user/message', {
  507. ...textMessage('context-1', 'runtime context'),
  508. source: { kind: 'plugin', plugin: 'context' },
  509. }, { surfaceOp: 'append' }),
  510. at(3, 'step/start', { turn: 1, step: 1 }),
  511. at(4, 'assistant/chunk', {
  512. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
  513. }),
  514. ])
  515. const running = snapshot(value)
  516. expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([
  517. 'turn-process', 'context', 'assistant-step',
  518. ])
  519. value.append(at(5, 'step/end', { turn: 1, step: 1 }))
  520. value.append(at(6, 'step/start', { turn: 1, step: 2 }))
  521. value.append(at(7, 'assistant/message', {
  522. turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
  523. }, { surfaceOp: 'append' }))
  524. value.flush()
  525. const answered = snapshot(value)
  526. expect(answered.order.map(key => answered.nodes.get(key)?.kind)).toEqual([
  527. 'turn-process', 'context', 'assistant-step', 'assistant-step',
  528. ])
  529. value.append(at(8, 'llm/retry', {
  530. retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal',
  531. policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
  532. failure: { code: 'TRANSPORT', message: 'temporary' },
  533. }))
  534. value.flush()
  535. const retried = snapshot(value)
  536. expect(retried.order.map(key => retried.nodes.get(key)?.kind)).toEqual([
  537. 'turn-process', 'context', 'assistant-step', 'model-retry',
  538. ])
  539. })
  540. it('establishes the answer boundary only when a streamed answer finalizes', () => {
  541. const value = assembler([
  542. at(40, 'turn/start', { turn: 4 }),
  543. at(41, 'step/start', { turn: 4, step: 1 }),
  544. at(42, 'assistant/chunk', {
  545. turn: 4, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
  546. }),
  547. at(43, 'assistant/chunk', {
  548. turn: 4, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' },
  549. }),
  550. ])
  551. const read = () => {
  552. const signature = snapshot(value).timeline.turns.get(4)?.data.get('turn-process')
  553. if (signature === undefined) throw new Error('turn-process signature is unavailable')
  554. return decodeTurnProcess(signature)
  555. }
  556. const streaming = read()
  557. value.append(at(44, 'assistant/message', {
  558. turn: 4, step: 1, message: assistantMessage('settled-4', 'answer'),
  559. }, { surfaceOp: 'append' }))
  560. value.flush()
  561. const settled = read()
  562. expect(streaming).toMatchObject({ answerAnchorSeq: null, answerStep: null })
  563. expect(settled.answerAnchorSeq).toBe(44)
  564. expect(settled.answerStep).toBe(1)
  565. })
  566. it('anchors a streamed non-text answer from its block start', () => {
  567. const value = assembler([
  568. at(50, 'turn/start', { turn: 5 }),
  569. at(51, 'step/start', { turn: 5, step: 1 }),
  570. at(52, 'assistant/chunk', {
  571. turn: 5, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'image' },
  572. }),
  573. ])
  574. const current = snapshot(value)
  575. const process = node(current, 'turn-process')
  576. const answer = node(current, 'assistant-step')
  577. const signature = current.timeline.turns.get(5)?.data.get('turn-process')
  578. expect(process?.anchorSeq).toBe(51.9)
  579. expect(answer?.anchorSeq).toBe(52)
  580. expect(signature === undefined ? undefined : decodeTurnProcess(signature))
  581. .toMatchObject({ answerAnchorSeq: null, answerStep: null })
  582. })
  583. it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
  584. const value = assembler([
  585. at(1, 'turn/start', { turn: 1 }),
  586. at(2, 'step/start', { turn: 1, step: 1 }),
  587. at(3, 'assistant/chunk', {
  588. turn: 1,
  589. step: 1,
  590. chunk: { type: 'text-delta', index: 0, text: 'streaming' },
  591. }),
  592. ])
  593. const runningSnapshot = snapshot(value)
  594. const running = node(runningSnapshot, 'assistant-step')
  595. expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] })
  596. const order = runningSnapshot.order
  597. value.append(at(4, 'assistant/message', {
  598. turn: 1,
  599. step: 1,
  600. message: assistantMessage('assistant-1', 'settled'),
  601. }, { surfaceOp: 'append' }))
  602. value.flush()
  603. const settledSnapshot = snapshot(value)
  604. const settled = node(settledSnapshot, 'assistant-step')
  605. expect(settled?.key).toBe(running?.key)
  606. expect(settledSnapshot.order).toBe(order)
  607. expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] })
  608. const interruptedValue = assembler([
  609. at(10, 'turn/start', { turn: 2 }),
  610. at(11, 'step/start', { turn: 2, step: 1 }),
  611. at(12, 'assistant/chunk', {
  612. turn: 2,
  613. step: 1,
  614. chunk: { type: 'text-delta', index: 0, text: 'partial' },
  615. }),
  616. at(13, 'step/end', { turn: 2, step: 1 }),
  617. ])
  618. const interrupted = node(snapshot(interruptedValue), 'assistant-step')
  619. expect(interrupted?.data).toMatchObject({ status: 'interrupted' })
  620. expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
  621. const markedValue = assembler([
  622. at(20, 'turn/start', { turn: 3 }),
  623. at(21, 'step/start', { turn: 3, step: 1 }),
  624. at(22, 'assistant/message', {
  625. turn: 3,
  626. step: 1,
  627. message: assistantMessage('assistant-3', 'cut short'),
  628. interrupted: true,
  629. }, { surfaceOp: 'append' }),
  630. ])
  631. const marked = node(snapshot(markedValue), 'assistant-step')
  632. expect(marked?.data).toMatchObject({ status: 'interrupted', blocks: [{ kind: 'text', text: 'cut short' }] })
  633. expect((marked?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
  634. const hiddenValue = assembler([
  635. at(20, 'turn/start', { turn: 3 }),
  636. at(21, 'step/start', { turn: 3, step: 1 }),
  637. at(22, 'llm/retry', {
  638. retryId: 'retry-hidden',
  639. turn: 3,
  640. step: 1,
  641. provider: 'fake',
  642. mode: 'normal',
  643. policyKey: 'fake-normal',
  644. retry: 1,
  645. maxRetries: 2,
  646. delayMs: 10,
  647. failure: { code: 'TRANSPORT', message: 'temporary' },
  648. }),
  649. ])
  650. expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined()
  651. const toolOnlyValue = assembler([
  652. at(30, 'turn/start', { turn: 4 }),
  653. at(31, 'step/start', { turn: 4, step: 1 }),
  654. at(32, 'assistant/chunk', {
  655. turn: 4,
  656. step: 1,
  657. chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' },
  658. }),
  659. at(33, 'assistant/message', {
  660. turn: 4,
  661. step: 1,
  662. message: {
  663. ...assistantMessage('assistant-tool-only', ''),
  664. content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
  665. },
  666. }, { surfaceOp: 'append' }),
  667. ])
  668. const toolOnlySnapshot = snapshot(toolOnlyValue)
  669. expect(toolOnlySnapshot.order).toEqual([])
  670. expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden')
  671. expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{
  672. kind: 'assistant',
  673. seq: 33,
  674. timing: { firstTokenTime: 1_700_000_000_032 },
  675. }])
  676. const interruptedToolOnlyValue = assembler([
  677. at(35, 'turn/start', { turn: 5 }),
  678. at(36, 'step/start', { turn: 5, step: 1 }),
  679. at(37, 'assistant/chunk', {
  680. turn: 5,
  681. step: 1,
  682. chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
  683. }),
  684. at(38, 'step/end', { turn: 5, step: 1 }),
  685. ])
  686. const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step')
  687. expect(interruptedToolOnly?.visibility).toBe('visible')
  688. expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' })
  689. const retryTimingValue = assembler([
  690. at(50, 'turn/start', { turn: 6 }),
  691. at(51, 'step/start', { turn: 6, step: 1 }),
  692. at(52, 'assistant/chunk', {
  693. turn: 6,
  694. step: 1,
  695. chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
  696. }),
  697. at(53, 'llm/retry', {
  698. retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal',
  699. policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
  700. failure: { code: 'TRANSPORT', message: 'temporary' },
  701. }),
  702. at(54, 'assistant/chunk', {
  703. turn: 6,
  704. step: 1,
  705. chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
  706. }),
  707. at(55, 'assistant/message', {
  708. turn: 6,
  709. step: 1,
  710. message: assistantMessage('assistant-retried', 'done'),
  711. }, { surfaceOp: 'append' }),
  712. ])
  713. const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode
  714. expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052)
  715. const partialWindow = assembler([
  716. at(40, 'assistant/chunk', {
  717. turn: 5,
  718. step: 2,
  719. chunk: { type: 'text-delta', index: 0, text: 'loaded partial' },
  720. }),
  721. at(41, 'step/end', { turn: 5, step: 2 }),
  722. ], true)
  723. const recovered = node(snapshot(partialWindow), 'assistant-step')
  724. expect(recovered?.data).toMatchObject({
  725. status: 'interrupted',
  726. blocks: [{ kind: 'text', text: 'loaded partial' }],
  727. })
  728. })
  729. it('folds packed Assistant runs to the same Chat and Turn Tail state as scalar deltas', () => {
  730. const runningHistory = [
  731. at(1, 'turn/start', { turn: 1 }),
  732. at(2, 'step/start', { turn: 1, step: 1 }),
  733. at(3, 'assistant/chunk', {
  734. turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
  735. }, { time: 1_000 }),
  736. at(4, 'assistant/chunk', {
  737. turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
  738. }, { time: 1_000 }),
  739. at(5, 'assistant/chunk', {
  740. turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\t' },
  741. }, { time: 995 }),
  742. at(6, 'assistant/chunk', {
  743. turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' },
  744. }, { time: 1_004 }),
  745. at(7, 'assistant/chunk', {
  746. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: '' },
  747. }),
  748. at(8, 'assistant/chunk', {
  749. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' },
  750. }),
  751. at(9, 'assistant/chunk', {
  752. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'ing' },
  753. }),
  754. at(10, 'assistant/chunk', {
  755. turn: 1, step: 1,
  756. chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '' },
  757. }),
  758. at(11, 'assistant/chunk', {
  759. turn: 1, step: 1,
  760. chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '{"x":' },
  761. }),
  762. at(12, 'assistant/chunk', {
  763. turn: 1, step: 1,
  764. chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '1}' },
  765. }),
  766. ]
  767. const scalar = assembler(runningHistory)
  768. const packedHistory = packedInputs(runningHistory)
  769. expect(packedHistory.filter(input => input.event.type.startsWith('chunkrow/'))).toHaveLength(3)
  770. const packed = assembler(packedHistory)
  771. expect(snapshot(packed)).toEqual(snapshot(scalar))
  772. const running = node(snapshot(packed), 'assistant-step')
  773. expect(running).toMatchObject({ anchorSeq: 6 })
  774. expect(running?.data).toMatchObject({
  775. time: 1_004,
  776. blocks: [
  777. { kind: 'text', text: ' \tanswer' },
  778. { kind: 'reasoning', text: 'thinking' },
  779. { kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
  780. ],
  781. })
  782. for (const value of [scalar, packed]) {
  783. value.append(at(13, 'step/end', { turn: 1, step: 1 }))
  784. value.append(at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
  785. value.flush()
  786. }
  787. expect(snapshot(packed)).toEqual(snapshot(scalar))
  788. expect(node(snapshot(packed), 'turn-tail')?.anchorSeq).toBe(12.2)
  789. const partialHistory = [
  790. ...runningHistory.slice(2),
  791. at(13, 'step/end', { turn: 1, step: 1 }),
  792. at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  793. ]
  794. const partialScalar = snapshot(assembler(partialHistory, true))
  795. const partialPacked = snapshot(assembler(packedInputs(partialHistory), true))
  796. expect(partialPacked).toEqual(partialScalar)
  797. expect(node(partialPacked, 'assistant-step')?.data).toMatchObject({ status: 'interrupted' })
  798. expect(node(partialPacked, 'turn-tail')?.anchorSeq).toBe(12.2)
  799. const finalizedHistory = [
  800. at(20, 'turn/start', { turn: 2 }),
  801. at(21, 'step/start', { turn: 2, step: 1 }),
  802. at(22, 'assistant/chunk', {
  803. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
  804. }, { time: 2_000 }),
  805. at(23, 'assistant/chunk', {
  806. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
  807. }, { time: 1_999 }),
  808. at(24, 'assistant/chunk', {
  809. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' },
  810. }, { time: 2_000 }),
  811. at(25, 'llm/retry', {
  812. retryId: 'packed-retry', turn: 2, step: 1, provider: 'fake', mode: 'normal',
  813. policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
  814. failure: { code: 'TRANSPORT', message: 'temporary' },
  815. }),
  816. at(26, 'assistant/chunk', {
  817. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
  818. }),
  819. at(27, 'assistant/chunk', {
  820. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'second' },
  821. }),
  822. at(28, 'assistant/chunk', {
  823. turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' attempt' },
  824. }),
  825. at(29, 'assistant/message', {
  826. turn: 2, step: 1, message: assistantMessage('packed-final', 'done'),
  827. }, { surfaceOp: 'append' }),
  828. ]
  829. const finalizedScalar = snapshot(assembler(finalizedHistory))
  830. const finalizedPacked = snapshot(assembler(packedInputs(finalizedHistory)))
  831. expect(finalizedPacked).toEqual(finalizedScalar)
  832. const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
  833. expect(finalNode?.timing?.firstTokenTime).toBe(1_999)
  834. const namedToolHistory = [
  835. at(40, 'turn/start', { turn: 3 }),
  836. at(41, 'step/start', { turn: 3, step: 1 }),
  837. ...[42, 43, 44].map(seq => at(seq, 'assistant/chunk', {
  838. turn: 3, step: 1,
  839. chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
  840. }, { time: 4_000 + seq - 42 })),
  841. at(45, 'assistant/message', {
  842. turn: 3,
  843. step: 1,
  844. message: {
  845. ...assistantMessage('named-tool-final', ''),
  846. content: [{ type: 'tool-call', id: 'call-2', name: 'read', arguments: '' }],
  847. },
  848. }, { surfaceOp: 'append' }),
  849. ]
  850. const namedToolScalar = snapshot(assembler(namedToolHistory))
  851. const namedToolPacked = snapshot(assembler(packedInputs(namedToolHistory)))
  852. expect(namedToolPacked).toEqual(namedToolScalar)
  853. const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
  854. expect(namedTool?.timing?.firstTokenTime).toBe(4_000)
  855. })
  856. it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => {
  857. const value = assembler([
  858. at(1, 'turn/start', { turn: 1 }),
  859. at(2, 'step/start', { turn: 1, step: 1 }),
  860. at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }),
  861. ])
  862. const runningSnapshot = snapshot(value)
  863. const running = node(runningSnapshot, 'tool-call')
  864. expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' })
  865. const order = runningSnapshot.order
  866. value.append(at(4, 'tool/result', {
  867. turn: 1,
  868. step: 1,
  869. message: toolResult('root', 'done', true),
  870. error: { name: 'ToolError', code: 'failed' },
  871. meta: { presentation: 'raw' },
  872. }, { surfaceOp: 'append' }))
  873. value.flush()
  874. const settledSnapshot = snapshot(value)
  875. const settled = node(settledSnapshot, 'tool-call')
  876. expect(settled?.key).toBe(running?.key)
  877. expect(settledSnapshot.order).toBe(order)
  878. expect((settled?.data as ToolChatData).root).toMatchObject({
  879. kind: 'tool-result',
  880. callId: 'root',
  881. call: { name: 'code', argsRaw: '{}' },
  882. content: [{ type: 'text', text: 'done' }],
  883. isError: true,
  884. error: { name: 'ToolError', code: 'failed' },
  885. meta: { presentation: 'raw' },
  886. })
  887. const history = assembler([
  888. at(14, 'tool/code-dispatch-start', {
  889. rootCallId: 'history-root',
  890. parentCallId: 'history-root',
  891. subCallId: 'child',
  892. name: 'read',
  893. arguments: { path: 'README.md' },
  894. }),
  895. at(15, 'tool/code-dispatch', {
  896. rootCallId: 'history-root',
  897. parentCallId: 'history-root',
  898. subCallId: 'child',
  899. name: 'read',
  900. arguments: { path: 'README.md' },
  901. isError: false,
  902. content: [{ type: 'text', text: 'contents' }],
  903. }),
  904. at(16, 'tool/result', {
  905. turn: 2,
  906. step: 1,
  907. message: toolResult('history-root', 'root done'),
  908. }, { surfaceOp: 'append' }),
  909. ], true)
  910. const before = node(snapshot(history), 'tool-call')
  911. expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
  912. { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
  913. ])
  914. history.prepend([
  915. at(10, 'turn/start', { turn: 2 }),
  916. at(11, 'step/start', { turn: 2, step: 1 }),
  917. at(13, 'tool/call', {
  918. turn: 2,
  919. step: 1,
  920. callId: 'history-root',
  921. name: 'code',
  922. arguments: '{}',
  923. }),
  924. ], false)
  925. history.flush()
  926. const after = node(snapshot(history), 'tool-call')
  927. expect(after?.key).toBe(before?.key)
  928. expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
  929. { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
  930. ])
  931. const firstChild = (after?.data as ToolChatData).root.subCalls[0]
  932. history.append(at(17, 'tool/code-dispatch-start', {
  933. rootCallId: 'history-root',
  934. parentCallId: 'history-root',
  935. subCallId: 'second-child',
  936. name: 'write',
  937. arguments: { path: 'out.txt' },
  938. }))
  939. history.flush()
  940. const withSecondChild = node(snapshot(history), 'tool-call')
  941. expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild)
  942. })
  943. it('prepends an older turn without replacing already materialized nodes', () => {
  944. const value = assembler([
  945. at(20, 'turn/start', { turn: 2 }),
  946. at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }),
  947. at(22, 'step/start', { turn: 2, step: 1 }),
  948. at(23, 'assistant/message', {
  949. turn: 2,
  950. step: 1,
  951. message: assistantMessage('newer-assistant', 'newer answer'),
  952. }, { surfaceOp: 'append' }),
  953. at(24, 'step/end', { turn: 2, step: 1 }),
  954. at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }),
  955. ], true)
  956. const before = snapshot(value)
  957. const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '')
  958. const store = before.nodes
  959. value.prepend([
  960. at(10, 'turn/start', { turn: 1 }),
  961. at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }),
  962. at(12, 'step/start', { turn: 1, step: 1 }),
  963. at(13, 'assistant/message', {
  964. turn: 1,
  965. step: 1,
  966. message: assistantMessage('older-assistant', 'older answer'),
  967. }, { surfaceOp: 'append' }),
  968. at(14, 'step/end', { turn: 1, step: 1 }),
  969. at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  970. ], false)
  971. value.flush()
  972. const after = snapshot(value)
  973. expect(after.nodes).toBe(store)
  974. expect(after.nodes.get(existing?.key ?? '')).toBe(existing)
  975. expect(after.order).toHaveLength(before.order.length + 4)
  976. expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
  977. 'user', 'turn-process', 'assistant-step', 'turn-tail',
  978. 'user', 'turn-process', 'assistant-step', 'turn-tail',
  979. ])
  980. })
  981. it('appends a later turn without replacing nodes from the completed turn', () => {
  982. const value = assembler([
  983. at(1, 'turn/start', { turn: 1 }),
  984. at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
  985. at(3, 'step/start', { turn: 1, step: 1 }),
  986. at(4, 'assistant/message', {
  987. turn: 1,
  988. step: 1,
  989. message: assistantMessage('first-assistant', 'first answer'),
  990. }, { surfaceOp: 'append' }),
  991. at(5, 'step/end', { turn: 1, step: 1 }),
  992. at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  993. ])
  994. const before = snapshot(value)
  995. const oldOrder = before.order
  996. const oldNodes = oldOrder.map(key => before.nodes.get(key))
  997. value.append(at(7, 'turn/start', { turn: 2 }))
  998. value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }))
  999. value.flush()
  1000. const after = snapshot(value)
  1001. expect(after.nodes).toBe(before.nodes)
  1002. expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder)
  1003. expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes)
  1004. expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
  1005. 'user', 'turn-process', 'assistant-step', 'turn-tail', 'user',
  1006. ])
  1007. })
  1008. it('keeps branching unavailable when a tool result follows the closing Assistant', () => {
  1009. const value = assembler([
  1010. at(1, 'turn/start', { turn: 1 }),
  1011. at(2, 'step/start', { turn: 1, step: 1 }),
  1012. at(3, 'assistant/message', {
  1013. turn: 1,
  1014. step: 1,
  1015. message: assistantMessage('assistant-before-tool', 'running a tool'),
  1016. }, { surfaceOp: 'append' }),
  1017. at(4, 'tool/call', { turn: 1, step: 1, callId: 'late-tool', name: 'read', arguments: '{}' }),
  1018. at(5, 'tool/result', {
  1019. turn: 1,
  1020. step: 1,
  1021. message: toolResult('late-tool', 'done'),
  1022. }, { surfaceOp: 'append' }),
  1023. at(6, 'step/end', { turn: 1, step: 1 }),
  1024. at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  1025. ])
  1026. const tail = node(snapshot(value), 'turn-tail')?.data as TurnTailChatData
  1027. expect(tail.closing?.finalNode.seq).toBe(3)
  1028. expect(tail.branchUnavailable).toBe(true)
  1029. })
  1030. it('publishes exact Turn usage only after pagination supplies the full lifecycle window', () => {
  1031. const value = assembler([
  1032. at(3, 'assistant/message', {
  1033. turn: 1,
  1034. step: 1,
  1035. message: assistantMessage('usage-assistant', 'done'),
  1036. usage: {
  1037. inputTokens: 10,
  1038. outputTokens: 4,
  1039. totalTokens: 17,
  1040. cacheReadTokens: 2,
  1041. cacheWriteTokens: 1,
  1042. reasoningTokens: 1,
  1043. },
  1044. }, { surfaceOp: 'append' }),
  1045. at(4, 'step/end', { turn: 1, step: 1 }),
  1046. at(5, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  1047. ], true)
  1048. expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toBeUndefined()
  1049. value.prepend([
  1050. at(1, 'turn/start', { turn: 1 }),
  1051. at(2, 'step/start', { turn: 1, step: 1 }),
  1052. ], false)
  1053. value.flush()
  1054. expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toEqual({
  1055. uncachedInputTokens: 10,
  1056. outputTokens: 4,
  1057. totalTokens: 17,
  1058. cacheReadTokens: 2,
  1059. cacheWriteTokens: 1,
  1060. reasoningTokens: 1,
  1061. routes: [{ provider: 'fake', model: 'fake' }],
  1062. })
  1063. })
  1064. it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => {
  1065. const value = assembler([
  1066. at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }),
  1067. ], true)
  1068. const before = node(snapshot(value), 'user')
  1069. expect(before).toBeDefined()
  1070. value.prepend([
  1071. at(1, 'agent/inbox/spliced', {
  1072. target: 'next-step',
  1073. start: 0,
  1074. inserted: [textMessage('steer-1', 'change direction')],
  1075. }),
  1076. at(2, 'agent/inbox/spliced', {
  1077. target: 'next-step',
  1078. start: 0,
  1079. removedCount: 1,
  1080. inserted: [],
  1081. }),
  1082. ], false)
  1083. value.flush()
  1084. const after = node(snapshot(value), 'steering')
  1085. expect(after?.key).toBe(before?.key)
  1086. expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' })
  1087. expect(node(snapshot(value), 'user')).toBeUndefined()
  1088. })
  1089. it('orders claimed steering after the finalized Turn tail', () => {
  1090. const steering = textMessage('steer-after-answer', 'change direction')
  1091. const value = assembler([
  1092. at(1, 'turn/start', { turn: 1 }),
  1093. at(2, 'step/start', { turn: 1, step: 1 }),
  1094. at(3, 'assistant/message', {
  1095. turn: 1,
  1096. step: 1,
  1097. message: assistantMessage('assistant-before-steering', 'initial answer'),
  1098. }, { surfaceOp: 'append' }),
  1099. at(4, 'agent/inbox/spliced', {
  1100. target: 'next-step',
  1101. start: 0,
  1102. inserted: [steering],
  1103. }),
  1104. at(5, 'agent/inbox/spliced', {
  1105. target: 'next-step',
  1106. start: 0,
  1107. removedCount: 1,
  1108. inserted: [],
  1109. }),
  1110. at(6, 'user/message', steering, { surfaceOp: 'append' }),
  1111. at(7, 'step/end', { turn: 1, step: 1 }),
  1112. at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  1113. ])
  1114. const current = snapshot(value)
  1115. const steeringNode = node(current, 'steering')
  1116. expect(steeringNode).toBeDefined()
  1117. expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key)
  1118. })
  1119. it('classifies appended producer context from durable source metadata', () => {
  1120. const value = assembler([
  1121. at(1, 'user/message', {
  1122. ...textMessage('skill-context', 'follow these instructions'),
  1123. source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
  1124. }, { surfaceOp: 'append' }),
  1125. ])
  1126. expect(node(snapshot(value), 'context')?.data).toMatchObject({
  1127. kind: 'context',
  1128. provenance: { role: 'inject', label: 'demo-skill' },
  1129. form: 'instructions',
  1130. })
  1131. })
  1132. it('materializes series starts and system changes but not same-series config or tool changes', () => {
  1133. const tools = [{ name: 'read', description: 'Read', parameters: { type: 'object' } }]
  1134. const expandedTools = [...tools, { name: 'write', description: 'Write', parameters: { type: 'object' } }]
  1135. const value = assembler([
  1136. at(1, 'request/header', {
  1137. reason: 'initial',
  1138. header: { config: { provider: 'fake', model: 'fake' }, system: '# Initial', tools },
  1139. }),
  1140. at(2, 'request/header', {
  1141. reason: 'change',
  1142. header: {
  1143. config: { provider: 'fake', model: 'fake' },
  1144. system: '# Initial',
  1145. tools: expandedTools,
  1146. },
  1147. }),
  1148. at(3, 'request/header', {
  1149. reason: 'change',
  1150. header: {
  1151. config: { provider: 'fake', model: 'fake', maxTokens: 1_024 },
  1152. system: '# Initial',
  1153. tools: expandedTools,
  1154. },
  1155. }),
  1156. at(4, 'request/header', {
  1157. reason: 'change',
  1158. startsSeries: true,
  1159. header: {
  1160. config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
  1161. system: '# Initial',
  1162. tools: expandedTools,
  1163. },
  1164. }),
  1165. at(5, 'request/header', {
  1166. reason: 'resume',
  1167. header: {
  1168. config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
  1169. system: '# Initial',
  1170. tools: expandedTools,
  1171. },
  1172. }),
  1173. at(6, 'request/header', {
  1174. reason: 'change',
  1175. header: {
  1176. config: { provider: 'fake', model: 'fake', maxTokens: 2_048 },
  1177. system: '# Updated',
  1178. tools: expandedTools,
  1179. },
  1180. }),
  1181. ])
  1182. const prompts = snapshot(value).nodes.values()
  1183. .filter(candidate => candidate.kind === 'system-prompt')
  1184. expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([
  1185. { anchorSeq: 1, data: { text: '# Initial' } },
  1186. { anchorSeq: 4, data: { text: '# Initial' } },
  1187. { anchorSeq: 5, data: { text: '# Initial' } },
  1188. { anchorSeq: 6, data: { text: '# Updated' } },
  1189. ])
  1190. const windowed = assembler([
  1191. at(10, 'request/header', {
  1192. reason: 'resume',
  1193. header: { config: { provider: 'fake', model: 'fake' }, system: '# Resumed prompt' },
  1194. }),
  1195. ], true)
  1196. const systemless = assembler([
  1197. at(20, 'request/header', {
  1198. reason: 'initial',
  1199. header: { config: { provider: 'fake', model: 'fake' } },
  1200. }),
  1201. ])
  1202. expect(node(snapshot(windowed), 'system-prompt')?.data).toEqual({ text: '# Resumed prompt' })
  1203. expect(node(snapshot(systemless), 'system-prompt')).toBeUndefined()
  1204. windowed.prepend([
  1205. at(5, 'request/header', {
  1206. reason: 'initial',
  1207. header: { config: { provider: 'fake', model: 'fake' }, system: '# Original prompt' },
  1208. }),
  1209. ], false)
  1210. windowed.flush()
  1211. const restored = snapshot(windowed)
  1212. const restoredPrompts = restored.order.flatMap((key) => {
  1213. const candidate = restored.nodes.get(key)
  1214. return candidate?.kind === 'system-prompt' ? [candidate] : []
  1215. })
  1216. expect(restoredPrompts.map(prompt => prompt.data)).toEqual([
  1217. { text: '# Original prompt' },
  1218. { text: '# Resumed prompt' },
  1219. ])
  1220. })
  1221. it('orders the system field before the request messages while preserving message order', () => {
  1222. const value = assembler([
  1223. at(1, 'turn/start', { turn: 1 }),
  1224. at(2, 'step/start', { turn: 1, step: 1 }),
  1225. at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
  1226. at(4, 'user/message', {
  1227. ...textMessage('runtime-context', 'runtime facts'),
  1228. source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt', form: 'snapshot' },
  1229. }, { surfaceOp: 'append' }),
  1230. at(5, 'request/header', {
  1231. reason: 'initial',
  1232. header: { config: { provider: 'fake', model: 'fake' }, system: '# System' },
  1233. }),
  1234. ])
  1235. const current = snapshot(value)
  1236. expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
  1237. 'system-prompt',
  1238. 'user',
  1239. 'context',
  1240. ])
  1241. expect(node(current, 'system-prompt')?.anchorSeq).toBe(1)
  1242. })
  1243. it('keeps the initial system prompt before the opening User as Turn process state changes', () => {
  1244. const value = assembler([
  1245. at(1, 'turn/start', { turn: 1 }),
  1246. at(2, 'step/start', { turn: 1, step: 1 }),
  1247. at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
  1248. at(4, 'user/message', {
  1249. ...textMessage('runtime-context', 'runtime facts'),
  1250. source: { kind: 'plugin', plugin: 'context' },
  1251. }, { surfaceOp: 'append' }),
  1252. at(5, 'request/header', {
  1253. reason: 'initial',
  1254. header: { config: { provider: 'fake', model: 'fake' }, system: '# System' },
  1255. }),
  1256. ])
  1257. const kinds = () => {
  1258. const current = snapshot(value)
  1259. return current.order.map(key => current.nodes.get(key)?.kind)
  1260. }
  1261. const promptKey = node(snapshot(value), 'system-prompt')?.key
  1262. expect(kinds()).toEqual(['system-prompt', 'user', 'context'])
  1263. value.append(at(6, 'assistant/chunk', {
  1264. turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
  1265. }))
  1266. value.flush()
  1267. expect(kinds()).toEqual([
  1268. 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
  1269. ])
  1270. value.append(at(7, 'step/end', { turn: 1, step: 1 }))
  1271. value.append(at(8, 'step/start', { turn: 1, step: 2 }))
  1272. value.append(at(9, 'assistant/message', {
  1273. turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
  1274. }, { surfaceOp: 'append' }))
  1275. value.append(at(10, 'step/end', { turn: 1, step: 2 }))
  1276. value.append(at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
  1277. value.flush()
  1278. expect(kinds()).toEqual([
  1279. 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
  1280. ])
  1281. expect(node(snapshot(value), 'system-prompt')?.key).toBe(promptKey)
  1282. })
  1283. it('keeps an append-only later user turn in the existing system-prompt series', () => {
  1284. const value = assembler([
  1285. at(1, 'turn/start', { turn: 1 }),
  1286. at(2, 'step/start', { turn: 1, step: 1 }),
  1287. at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
  1288. at(4, 'request/header', {
  1289. reason: 'initial',
  1290. header: { config: { provider: 'fake', model: 'fake' }, system: '# System' },
  1291. }),
  1292. at(5, 'step/end', { turn: 1, step: 1 }),
  1293. at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  1294. at(7, 'turn/start', { turn: 2 }),
  1295. at(8, 'step/start', { turn: 2, step: 1 }),
  1296. at(9, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }),
  1297. ])
  1298. const current = snapshot(value)
  1299. const ordered = current.order.flatMap((key) => {
  1300. const candidate = current.nodes.get(key)
  1301. return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
  1302. })
  1303. expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user'])
  1304. })
  1305. it('keeps a windowed System prompt in place when prepend supplies the preceding header', () => {
  1306. const reasons = ['change', 'resume', 'series'] as const
  1307. for (const reason of reasons) {
  1308. const windowedSystem = reason === 'series' ? '# Original' : '# Windowed'
  1309. const windowed = assembler([
  1310. at(5, 'turn/start', { turn: 2 }),
  1311. at(6, 'step/start', { turn: 2, step: 1 }),
  1312. at(7, 'user/message', textMessage(`second-user-${reason}`, 'second'), { surfaceOp: 'append' }),
  1313. at(8, 'request/header', {
  1314. reason,
  1315. header: { config: { provider: 'fake', model: 'fake' }, system: windowedSystem },
  1316. }),
  1317. ], true)
  1318. const before = snapshot(windowed)
  1319. const prompt = node(before, 'system-prompt')
  1320. const user = node(before, 'user')
  1321. if (prompt === undefined || user === undefined) throw new Error('windowed prompt fixture is incomplete')
  1322. const stableOrder = [user.key, prompt.key]
  1323. expect(prompt.anchorSeq).toBe(8)
  1324. expect(before.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder)
  1325. windowed.prepend([
  1326. at(1, 'turn/start', { turn: 1 }),
  1327. at(2, 'step/start', { turn: 1, step: 1 }),
  1328. at(3, 'user/message', textMessage(`first-user-${reason}`, 'first'), { surfaceOp: 'append' }),
  1329. at(4, 'request/header', {
  1330. reason: 'initial',
  1331. header: { config: { provider: 'fake', model: 'fake' }, system: '# Original' },
  1332. }),
  1333. ], false)
  1334. windowed.flush()
  1335. const restored = snapshot(windowed)
  1336. const prompts = restored.order.flatMap((key) => {
  1337. const candidate = restored.nodes.get(key)
  1338. return candidate?.kind === 'system-prompt' ? [candidate] : []
  1339. })
  1340. expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 8])
  1341. expect(restored.nodes.get(prompt.key)?.anchorSeq).toBe(8)
  1342. expect(restored.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder)
  1343. }
  1344. })
  1345. it('repeats an unchanged system prompt after a surface rewrite and before an explicit later series', () => {
  1346. const value = assembler([
  1347. at(1, 'turn/start', { turn: 1 }),
  1348. at(2, 'step/start', { turn: 1, step: 1 }),
  1349. at(3, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
  1350. at(4, 'request/header', {
  1351. reason: 'initial',
  1352. header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' },
  1353. }),
  1354. at(5, 'user/message', {
  1355. ...textMessage('compacted', 'summary'),
  1356. source: { kind: 'plugin', plugin: 'compact' },
  1357. }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }),
  1358. at(6, 'request/header', {
  1359. reason: 'series',
  1360. header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' },
  1361. }),
  1362. at(7, 'step/end', { turn: 1, step: 1 }),
  1363. at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  1364. at(9, 'turn/start', { turn: 2 }),
  1365. at(10, 'step/start', { turn: 2, step: 1 }),
  1366. at(11, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }),
  1367. at(12, 'request/header', {
  1368. reason: 'series',
  1369. header: { config: { provider: 'fake', model: 'fake' }, system: '# Same' },
  1370. }),
  1371. ])
  1372. const current = snapshot(value)
  1373. const ordered = current.order.flatMap((key) => {
  1374. const candidate = current.nodes.get(key)
  1375. return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
  1376. })
  1377. expect(ordered.map(candidate => candidate?.kind)).toEqual([
  1378. 'system-prompt', 'user', 'system-prompt', 'system-prompt', 'user',
  1379. ])
  1380. expect(ordered.filter(candidate => candidate?.kind === 'system-prompt')
  1381. .map(candidate => candidate?.anchorSeq)).toEqual([1, 6, 9])
  1382. })
  1383. it('associates each direct message with its immediately following session recall', () => {
  1384. const value = assembler([
  1385. at(1, 'user/message', textMessage('citing-research', '@Research notes what changed?'), { surfaceOp: 'append' }),
  1386. at(2, 'user/message', {
  1387. ...textMessage('research-context', 'snapshot'),
  1388. source: {
  1389. kind: 'session-reference',
  1390. form: 'recall',
  1391. version: 1,
  1392. references: [{ sessionId: 'source-a', label: 'Research notes' }],
  1393. },
  1394. }, { surfaceOp: 'append' }),
  1395. at(3, 'user/message', textMessage('citing-review', '@Review next'), { surfaceOp: 'append' }),
  1396. at(4, 'user/message', {
  1397. ...textMessage('review-context', 'snapshot'),
  1398. source: {
  1399. kind: 'session-reference',
  1400. form: 'recall',
  1401. version: 1,
  1402. references: [{ sessionId: 'source-b', label: 'Review' }],
  1403. },
  1404. }, { surfaceOp: 'append' }),
  1405. at(6, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }),
  1406. ])
  1407. const current = snapshot(value)
  1408. const messages = [...current.nodes.values()]
  1409. .filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
  1410. const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user')
  1411. expect(messages.map(candidate => candidate.kind)).toEqual(['user', 'context', 'user', 'context', 'user'])
  1412. expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
  1413. expect(users[1]?.data).toMatchObject({ referenceLabels: ['Review'] })
  1414. expect(users[2]?.data).not.toHaveProperty('referenceLabels')
  1415. })
  1416. it('updates an already published direct node when its following recall arrives', () => {
  1417. const value = assembler([
  1418. at(1, 'user/message', textMessage('citing-user', '@Research notes what changed?'), { surfaceOp: 'append' }),
  1419. ])
  1420. const before = node(snapshot(value), 'user')
  1421. expect(before?.data).not.toHaveProperty('referenceLabels')
  1422. value.append(at(2, 'user/message', {
  1423. ...textMessage('reference-context', 'snapshot'),
  1424. source: {
  1425. kind: 'session-reference',
  1426. form: 'recall',
  1427. version: 1,
  1428. references: [{ sessionId: 'source-a', label: 'Research notes' }],
  1429. },
  1430. }, { surfaceOp: 'append' }))
  1431. value.flush()
  1432. const current = snapshot(value)
  1433. const nodes = [...current.nodes.values()]
  1434. .filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
  1435. expect(nodes.map(candidate => candidate.kind)).toEqual(['user', 'context'])
  1436. expect(nodes[0]?.key).toBe(before?.key)
  1437. expect(nodes[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
  1438. expect(current.legacy.nodes[0]).toMatchObject({ referenceLabels: ['Research notes'] })
  1439. })
  1440. it('associates a claimed steering message with its following recall', () => {
  1441. const steering = textMessage('steering-reference', '@Research notes continue')
  1442. const value = assembler([
  1443. at(1, 'agent/inbox/spliced', {
  1444. target: 'next-step',
  1445. start: 0,
  1446. inserted: [steering],
  1447. }),
  1448. at(2, 'agent/inbox/spliced', {
  1449. target: 'next-step',
  1450. start: 0,
  1451. removedCount: 1,
  1452. inserted: [],
  1453. }),
  1454. at(3, 'user/message', steering, { surfaceOp: 'append' }),
  1455. at(4, 'user/message', {
  1456. ...textMessage('steering-reference-context', 'snapshot'),
  1457. source: {
  1458. kind: 'session-reference',
  1459. form: 'recall',
  1460. version: 1,
  1461. references: [{ sessionId: 'source-a', label: 'Research notes' }],
  1462. },
  1463. }, { surfaceOp: 'append' }),
  1464. ])
  1465. expect(node(snapshot(value), 'steering')?.data).toMatchObject({
  1466. messageId: 'steering-reference',
  1467. referenceLabels: ['Research notes'],
  1468. })
  1469. })
  1470. it('keeps replacement copies out of Chat business nodes', () => {
  1471. const value = assembler([
  1472. at(1, 'turn/start', { turn: 1 }),
  1473. at(2, 'step/start', { turn: 1, step: 1 }),
  1474. at(3, 'user/message', {
  1475. ...textMessage('replacement-user', 'model-only context'),
  1476. source: { kind: 'plugin', plugin: 'foreign' },
  1477. }, { surfaceOp: { op: 'replace', start: 1, end: 1 } }),
  1478. at(4, 'assistant/message', {
  1479. turn: 1,
  1480. step: 1,
  1481. message: assistantMessage('replacement-assistant', 'rewritten answer'),
  1482. }, { surfaceOp: { op: 'replace', start: 2, end: 2 } }),
  1483. at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }),
  1484. at(6, 'tool/result', {
  1485. turn: 1,
  1486. step: 1,
  1487. message: toolResult('root', 'pruned result'),
  1488. }, { surfaceOp: { op: 'replace', start: 3, end: 3 } }),
  1489. ])
  1490. const current = snapshot(value)
  1491. expect(node(current, 'user')).toBeUndefined()
  1492. expect(node(current, 'context')).toBeUndefined()
  1493. expect(node(current, 'assistant-step')).toBeUndefined()
  1494. expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind')
  1495. })
  1496. it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => {
  1497. const retry = assembler([
  1498. at(1, 'turn/start', { turn: 1 }),
  1499. at(2, 'step/start', { turn: 1, step: 1 }),
  1500. at(3, 'llm/retry', {
  1501. retryId: 'retry-1',
  1502. turn: 1,
  1503. step: 1,
  1504. provider: 'fake',
  1505. mode: 'normal',
  1506. policyKey: 'fake-normal',
  1507. retry: 1,
  1508. maxRetries: 2,
  1509. delayMs: 10,
  1510. failure: { code: 'TRANSPORT', message: 'first' },
  1511. }),
  1512. at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }),
  1513. at(5, 'llm/retry', {
  1514. retryId: 'retry-1',
  1515. turn: 1,
  1516. step: 1,
  1517. provider: 'fake',
  1518. mode: 'normal',
  1519. policyKey: 'fake-normal',
  1520. retry: 2,
  1521. maxRetries: 2,
  1522. delayMs: 20,
  1523. failure: { code: 'TRANSPORT', message: 'second' },
  1524. }),
  1525. at(6, 'step/end', { turn: 1, step: 1 }),
  1526. at(7, 'turn/end', {
  1527. turn: 1,
  1528. reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
  1529. }),
  1530. ])
  1531. const retryNode = node(snapshot(retry), 'model-retry')
  1532. const retryData = retryNode?.data as RetryChatData
  1533. expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled'])
  1534. expect(node(snapshot(retry), 'turn-error')?.data).toMatchObject({
  1535. kind: 'turn-error',
  1536. turn: 1,
  1537. message: 'failed',
  1538. code: 'TRANSPORT',
  1539. })
  1540. const compactions = assembler([
  1541. at(10, 'command/run', {
  1542. commandId: 'command-1',
  1543. name: 'compact',
  1544. source: { kind: 'user' },
  1545. }),
  1546. at(11, 'compaction/start', {
  1547. compactionId: 'manual-1',
  1548. sourceCommandId: 'command-1',
  1549. turn: null,
  1550. }),
  1551. at(12, 'compaction/summary', {
  1552. compactionId: 'manual-1',
  1553. sourceCommandId: 'command-1',
  1554. summary: [{ type: 'text', text: 'manual summary' }],
  1555. shadowedSeqs: [1, 2],
  1556. shadowedTokenCount: 100,
  1557. }),
  1558. at(13, 'user/message', {
  1559. ...textMessage('manual-checkpoint', 'checkpoint'),
  1560. source: {
  1561. kind: 'plugin',
  1562. plugin: 'compact',
  1563. compactionId: 'manual-1',
  1564. sourceCommandId: 'command-1',
  1565. },
  1566. }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
  1567. at(14, 'compaction/end', {
  1568. compactionId: 'manual-1',
  1569. sourceCommandId: 'command-1',
  1570. turn: null,
  1571. }),
  1572. at(15, 'command/done', {
  1573. commandId: 'command-1',
  1574. kind: 'success',
  1575. sourceEventSeq: 12,
  1576. }),
  1577. at(20, 'compaction/start', { compactionId: 'automatic-1', turn: null }),
  1578. at(21, 'compaction/summary', {
  1579. compactionId: 'automatic-1',
  1580. summary: [{ type: 'text', text: 'automatic summary' }],
  1581. shadowedSeqs: [3, 4],
  1582. shadowedTokenCount: 200,
  1583. }),
  1584. at(22, 'user/message', {
  1585. ...textMessage('automatic-checkpoint', 'checkpoint'),
  1586. source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' },
  1587. }, { surfaceOp: { op: 'replace', start: 3, end: 4 } }),
  1588. at(23, 'compaction/end', { compactionId: 'automatic-1', turn: null }),
  1589. ])
  1590. const manual = node(snapshot(compactions), 'manual-compaction')
  1591. expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({
  1592. summary: 'manual summary',
  1593. summaryEventSeq: 12,
  1594. })
  1595. const automatic = node(snapshot(compactions), 'compaction')
  1596. expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 })
  1597. expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1)
  1598. })
  1599. it('fills a landed compaction marker when an older page supplies its summary', () => {
  1600. const value = assembler([
  1601. at(13, 'user/message', {
  1602. ...textMessage('checkpoint', 'checkpoint'),
  1603. source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' },
  1604. }, { surfaceOp: { op: 'replace', start: 1, end: 8 } }),
  1605. ], true)
  1606. const before = node(snapshot(value), 'compaction')
  1607. expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null })
  1608. value.prepend([
  1609. at(9, 'compaction/start', { compactionId: 'compact-1', turn: null }),
  1610. at(10, 'compaction/summary', {
  1611. compactionId: 'compact-1',
  1612. summary: [
  1613. { type: 'text', text: 'older ' },
  1614. { type: 'image', data: 'ignored' },
  1615. { type: 'text', text: 'summary' },
  1616. ],
  1617. shadowedSeqs: [1, 2, 3],
  1618. shadowedTokenCount: 42,
  1619. }),
  1620. ], false)
  1621. value.flush()
  1622. const after = node(snapshot(value), 'compaction')
  1623. expect(after?.key).toBe(before?.key)
  1624. expect(after?.data).toMatchObject({
  1625. summary: 'older summary',
  1626. summaryEventSeq: 10,
  1627. shadowedItemCount: 3,
  1628. shadowedTokenCount: 42,
  1629. })
  1630. })
  1631. it('renders a historical compaction when its start remains outside the loaded window', () => {
  1632. const value = assembler([
  1633. at(10, 'compaction/summary', {
  1634. compactionId: 'compact-windowed',
  1635. summary: [{ type: 'text', text: 'loaded summary' }],
  1636. shadowedSeqs: [1, 2, 3],
  1637. shadowedTokenCount: 42,
  1638. }),
  1639. at(11, 'user/message', {
  1640. ...textMessage('checkpoint-windowed', 'checkpoint'),
  1641. source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' },
  1642. }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
  1643. ], true)
  1644. expect(node(snapshot(value), 'compaction')?.data).toMatchObject({
  1645. summary: 'loaded summary',
  1646. summaryEventSeq: 10,
  1647. shadowedItemCount: 3,
  1648. shadowedTokenCount: 42,
  1649. })
  1650. })
  1651. it('ignores legacy compaction transactions without correlation ids', () => {
  1652. const value = assembler([
  1653. at(10, 'compaction/start', { turn: null }),
  1654. at(11, 'compaction/end', { turn: null, error: 'This operation was aborted' }),
  1655. at(20, 'compaction/start', { turn: null }),
  1656. at(21, 'compaction/summary', {
  1657. summary: [{ type: 'text', text: 'legacy summary' }],
  1658. shadowedSeqs: [1, 2, 3],
  1659. shadowedTokenCount: 42,
  1660. }),
  1661. at(22, 'user/message', {
  1662. ...textMessage('legacy-checkpoint', 'checkpoint'),
  1663. source: { kind: 'plugin', plugin: 'compact' },
  1664. }, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
  1665. at(23, 'compaction/end', { turn: null }),
  1666. ], true)
  1667. expect(node(snapshot(value), 'compaction')).toBeUndefined()
  1668. })
  1669. it('ignores legacy retry and code-dispatch events without correlation ids', () => {
  1670. const value = assembler([
  1671. at(10, 'llm/retry', {
  1672. turn: 1,
  1673. step: 1,
  1674. provider: 'fake',
  1675. mode: 'normal',
  1676. policyKey: 'fake-normal',
  1677. retry: 1,
  1678. maxRetries: 2,
  1679. delayMs: 10,
  1680. failure: { code: 'TRANSPORT', message: 'first legacy retry' },
  1681. }),
  1682. at(11, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
  1683. at(20, 'llm/retry', {
  1684. turn: 2,
  1685. step: 1,
  1686. provider: 'fake',
  1687. mode: 'normal',
  1688. policyKey: 'fake-normal',
  1689. retry: 1,
  1690. maxRetries: 2,
  1691. delayMs: 10,
  1692. failure: { code: 'TRANSPORT', message: 'second legacy retry' },
  1693. }),
  1694. at(30, 'tool/code-dispatch-start', {
  1695. parentCallId: 'root',
  1696. subCallId: 'child',
  1697. name: 'legacy-subcall',
  1698. arguments: {},
  1699. }),
  1700. at(31, 'tool/code-dispatch', {
  1701. parentCallId: 'root',
  1702. subCallId: 'child',
  1703. name: 'legacy-subcall',
  1704. arguments: {},
  1705. content: [],
  1706. }),
  1707. ], true)
  1708. expect(node(snapshot(value), 'model-retry')).toBeUndefined()
  1709. expect(node(snapshot(value), 'tool-call')).toBeUndefined()
  1710. })
  1711. it('renders the exhausted-retry turn error in a partial tail window and after prepending the chain', () => {
  1712. const value = assembler([
  1713. at(5, 'llm/retry', {
  1714. retryId: 'retry-paged',
  1715. turn: 1,
  1716. step: 1,
  1717. provider: 'fake',
  1718. mode: 'normal',
  1719. policyKey: 'fake-normal',
  1720. retry: 2,
  1721. maxRetries: 2,
  1722. delayMs: 20,
  1723. failure: { code: 'TRANSPORT', message: 'second' },
  1724. }),
  1725. at(6, 'step/end', { turn: 1, step: 1 }),
  1726. at(7, 'turn/end', {
  1727. turn: 1,
  1728. reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
  1729. }),
  1730. ], true)
  1731. expect(node(snapshot(value), 'model-retry')).toBeUndefined()
  1732. expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({
  1733. kind: 'turn-error',
  1734. seq: 7,
  1735. turn: 1,
  1736. message: 'failed',
  1737. code: 'TRANSPORT',
  1738. })
  1739. value.prepend([
  1740. at(1, 'turn/start', { turn: 1 }),
  1741. at(2, 'step/start', { turn: 1, step: 1 }),
  1742. at(3, 'llm/retry', {
  1743. retryId: 'retry-paged',
  1744. turn: 1,
  1745. step: 1,
  1746. provider: 'fake',
  1747. mode: 'normal',
  1748. policyKey: 'fake-normal',
  1749. retry: 1,
  1750. maxRetries: 2,
  1751. delayMs: 10,
  1752. failure: { code: 'TRANSPORT', message: 'first' },
  1753. }),
  1754. at(4, 'llm/retry-started', {
  1755. retryId: 'retry-paged', turn: 1, step: 1, retry: 1,
  1756. }),
  1757. ], false)
  1758. value.flush()
  1759. const retry = node(snapshot(value), 'model-retry')
  1760. expect((retry?.data as RetryChatData).attempts).toHaveLength(2)
  1761. expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({
  1762. kind: 'turn-error',
  1763. seq: 7,
  1764. turn: 1,
  1765. message: 'failed',
  1766. code: 'TRANSPORT',
  1767. })
  1768. })
  1769. it('materializes a max-tokens notice and keeps completed and error turns clean', () => {
  1770. const value = assembler([
  1771. at(1, 'turn/start', { turn: 1 }),
  1772. at(2, 'step/start', { turn: 1, step: 1 }),
  1773. at(3, 'assistant/message', {
  1774. turn: 1, step: 1, message: assistantMessage('a1', 'truncated answer'),
  1775. }, { surfaceOp: 'append' }),
  1776. at(4, 'step/end', { turn: 1, step: 1 }),
  1777. at(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } }),
  1778. ])
  1779. const notice = node(snapshot(value), 'turn-max-tokens')
  1780. expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 })
  1781. expect(node(snapshot(value), 'turn-error')).toBeUndefined()
  1782. // The tail stays the turn's last node so its branch action survives; the
  1783. // notice slots between the truncated closing Assistant and the tail.
  1784. const tail = node(snapshot(value), 'turn-tail')
  1785. expect(notice?.anchorSeq).toBeLessThan(tail?.anchorSeq ?? Number.NEGATIVE_INFINITY)
  1786. expect(notice?.anchorSeq).toBeGreaterThan(3)
  1787. const completed = assembler([
  1788. at(1, 'turn/start', { turn: 1 }),
  1789. at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  1790. ])
  1791. expect(node(snapshot(completed), 'turn-max-tokens')).toBeUndefined()
  1792. const failed = assembler([
  1793. at(1, 'turn/start', { turn: 1 }),
  1794. at(2, 'turn/end', {
  1795. turn: 1,
  1796. reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
  1797. }),
  1798. ])
  1799. expect(node(snapshot(failed), 'turn-max-tokens')).toBeUndefined()
  1800. expect(node(snapshot(failed), 'turn-error')).toBeDefined()
  1801. })
  1802. it('keeps the max-tokens notice when the window starts after the owning turn/start', () => {
  1803. const value = assembler([
  1804. at(9, 'turn/end', { turn: 3, reason: { kind: 'max-tokens' } }),
  1805. ], true)
  1806. const notice = node(snapshot(value), 'turn-max-tokens')
  1807. expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 9, turn: 3 })
  1808. })
  1809. it('pins the max-tokens Definition edges the engine cannot reach', () => {
  1810. // The engine only hands start the single matched turn/end and never emits
  1811. // update Matches for this kind; these direct calls pin the declared
  1812. // behavior of both required Definition members anyway.
  1813. const match = (seq: number, type: string, data: unknown) => ({
  1814. event: { seq, time: seq * 1_000, type, data },
  1815. role: 'start',
  1816. location: undefined,
  1817. }) as unknown as Parameters<typeof turnMaxTokensDefinition.start>[1]
  1818. const context = (state: unknown, matches: unknown[] = []) => ({
  1819. key: 'k', kind: 'turn-max-tokens', id: '1', matches, start: undefined, state, current: new Map(),
  1820. }) as unknown as Parameters<NonNullable<typeof turnMaxTokensDefinition.buildViewNode>>[0]
  1821. const reader = { previous: () => undefined }
  1822. expect(() => turnMaxTokensDefinition.start(context(undefined), match(1, 'turn/start', { turn: 1 }), reader))
  1823. .toThrow('turn-max-tokens start requires a max-tokens turn/end')
  1824. const state = { turn: 1, seq: 5, time: 5_000 }
  1825. expect(turnMaxTokensDefinition.update(
  1826. context(state) as Parameters<typeof turnMaxTokensDefinition.update>[0],
  1827. match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
  1828. )).toBe(state)
  1829. expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull()
  1830. })
  1831. it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
  1832. const value = assembler([
  1833. at(12, 'tool/code-dispatch-start', {
  1834. rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
  1835. }),
  1836. at(13, 'tool/code-dispatch', {
  1837. rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
  1838. isError: false, content: [{ type: 'text', text: 'child result' }],
  1839. }),
  1840. at(14, 'tool/result', {
  1841. turn: 1,
  1842. step: 1,
  1843. message: toolResult('root', 'root result'),
  1844. }, { surfaceOp: 'append' }),
  1845. at(20, 'compaction/summary', {
  1846. compactionId: 'manual-1',
  1847. sourceCommandId: 'command-1',
  1848. summary: [{ type: 'text', text: 'manual summary' }],
  1849. shadowedSeqs: [1, 2],
  1850. shadowedTokenCount: 100,
  1851. }),
  1852. at(21, 'user/message', {
  1853. ...textMessage('manual-checkpoint', 'checkpoint'),
  1854. source: {
  1855. kind: 'plugin',
  1856. plugin: 'compact',
  1857. compactionId: 'manual-1',
  1858. sourceCommandId: 'command-1',
  1859. },
  1860. }, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
  1861. at(22, 'command/done', {
  1862. commandId: 'command-1',
  1863. kind: 'success',
  1864. sourceEventSeq: 20,
  1865. }),
  1866. ], true)
  1867. const tool = node(snapshot(value), 'tool-call')
  1868. const root = (tool?.data as ToolChatData).root
  1869. expect(root.subCalls).toHaveLength(1)
  1870. expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' })
  1871. const manual = node(snapshot(value), 'manual-compaction')
  1872. expect((manual?.data as ManualCompactionChatData)).toMatchObject({
  1873. command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } },
  1874. compaction: { summary: 'manual summary', summaryEventSeq: 20 },
  1875. })
  1876. })
  1877. })