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

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