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

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