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

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