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

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