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

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