| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522 |
- import { describe, expect, it } from 'vitest'
- import type {
- ChatConversationViewNode, ChatSnapshot,
- } from '@deepseek-ai/dsh-client-ui-chat/client'
- import type {
- SessionEventLikeEntry, SessionLiveEventEntry,
- } from '@deepseek-ai/dsh-api-session-controller/client'
- import {
- ConversationNodeAssembler,
- type ConversationNodeDefinition,
- type ConversationViewDefinition,
- } from '@deepseek-ai/dsh-client-ui-conversation/client'
- import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
- import { inspectSystemPrompt } from '../../ui-conversation/src/client/contract/system-prompt.ts'
- import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
- import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
- import type { StreamChunk } from '@deepseek-ai/dsh-llm'
- import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
- import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
- import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
- import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
- import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
- import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
- import { nextStepInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
- import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
- import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
- import { requestPromptDefinition, systemMessageDefinition } from '../src/client/conversation-nodes/request-prompt.ts'
- import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
- import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
- import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
- import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
- import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
- import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts'
- import type {
- AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
- } from '../src/client/contract/chat-nodes.ts'
- const DEFINITIONS: readonly ConversationNodeDefinition[] = [
- nextStepInboxDefinition,
- messageDefinition,
- systemMessageDefinition(inspectSystemPrompt),
- requestPromptDefinition(inspectRequestPrompt),
- assistantDefinition,
- turnProcessDefinition,
- toolDefinition,
- commandDefinition,
- compactionDefinition,
- retryDefinition,
- turnErrorDefinition,
- turnMaxTokensDefinition,
- turnTailDefinition,
- ]
- class TestEventDefinitions {
- entries(): readonly ConversationNodeDefinition[] {
- return DEFINITIONS
- }
- fallbackEntry(): ConversationNodeDefinition {
- return unknownFallbackDefinition
- }
- }
- class TestViewDefinitions {
- entries(): readonly ConversationViewDefinition[] {
- return [chatViewDefinition]
- }
- }
- function at(
- seq: number,
- type: string,
- data: unknown,
- extra: Record<string, unknown> = {},
- ): SessionLiveEventEntry {
- const payload = type === 'assistant/message' && typeof data === 'object' && data !== null
- ? { ...(data as Record<string, unknown>), stream: (data as { stream?: unknown }).stream ?? [] }
- : data
- return {
- type: 'event',
- event: {
- seq,
- time: 1_700_000_000_000 + seq,
- type,
- data: payload,
- ...extra,
- } as unknown as SessionEvent,
- }
- }
- function packedInputs(entries: readonly SessionLiveEventEntry[]): SessionEventLikeEntry[] {
- const output: SessionEventLikeEntry[] = []
- let active: {
- readonly turn: number
- readonly step: number
- readonly stream: AssistantStreamAccumulator
- last: SessionLiveEventEntry
- } | undefined
- const flush = (): void => {
- if (active === undefined) return
- output.push(at(active.last.event.seq, 'assistant/attempt', {
- turn: active.turn,
- step: active.step,
- stream: active.stream.snapshot(),
- }, { time: active.last.event.time }))
- active = undefined
- }
- for (const entry of entries) {
- const event = entry.event as unknown as {
- readonly type: string
- readonly time: number
- readonly data: { readonly turn?: number; readonly step?: number; readonly chunk?: StreamChunk }
- }
- if (event.type === 'assistant/live-chunk'
- && event.data.turn !== undefined
- && event.data.step !== undefined
- && event.data.chunk !== undefined) {
- if (active !== undefined && (active.turn !== event.data.turn || active.step !== event.data.step)) flush()
- const current = active ?? {
- turn: event.data.turn,
- step: event.data.step,
- stream: new AssistantStreamAccumulator(),
- last: entry,
- }
- active = current
- current.stream.push({ time: event.time, chunk: event.data.chunk })
- current.last = entry
- continue
- }
- const current = active
- if (event.type === 'assistant/message'
- && current !== undefined
- && current.turn === event.data.turn
- && current.step === event.data.step) {
- output.push({
- ...entry,
- event: {
- ...entry.event,
- data: { ...entry.event.data, stream: current.stream.snapshot() },
- } as SessionEvent,
- })
- active = undefined
- continue
- }
- flush()
- output.push(entry)
- }
- flush()
- return output
- }
- function assembler(entries: readonly SessionEventLikeEntry[] = [], hasMore = false): ConversationNodeAssembler {
- const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
- value.replaceWindow(entries, hasMore)
- value.activateTarget('chat')
- return value
- }
- function snapshot(value: ConversationNodeAssembler): ChatSnapshot {
- const current = value.snapshot('chat') as ChatSnapshot | undefined
- if (current === undefined) throw new Error('chat view was not registered')
- return current
- }
- function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
- return value.nodes.values().find(candidate => candidate.kind === kind)
- }
- function textMessage(id: string, text: string) {
- return {
- id,
- role: 'user',
- content: [{ type: 'text', text }],
- source: { kind: 'user' },
- }
- }
- function systemMessage(text: string) {
- return {
- id: `system-${text}`,
- role: 'system',
- content: text === '' ? [] : [{ type: 'text', text }],
- source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
- }
- }
- /** Append the first system prompt node or replace the node at `replaces`. */
- function systemAt(seq: number, text: string, replaces?: number): SessionLiveEventEntry {
- return at(seq, 'system/message', { turn: 1, step: 1, message: systemMessage(text) }, replaces === undefined
- ? { surfaceOp: 'append' }
- : { surfaceOp: { op: 'replace', startSeq: replaces, endSeq: replaces }, sourceEventSeqs: [replaces] })
- }
- /** Append an in-history prompt update the way the loop does on an `in-history` route. */
- function systemUpdateAt(seq: number, text: string, turn: number, step: number): SessionLiveEventEntry {
- return at(seq, 'system/message', { turn, step, message: systemMessage(text) }, { surfaceOp: 'append' })
- }
- function assistantMessage(id: string, text: string) {
- return {
- id,
- role: 'assistant',
- content: [{ type: 'text', text }],
- source: { kind: 'model', provider: 'fake', model: 'fake' },
- }
- }
- function toolResult(callId: string, text: string, isError = false) {
- return {
- id: `result-${callId}`,
- role: 'user',
- source: { kind: 'tool', callId },
- content: [{
- type: 'tool-result',
- toolCallId: callId,
- content: [{ type: 'text', text }],
- isError,
- }],
- }
- }
- describe('built-in conversation node Definitions', () => {
- it('rejects an unrelated event passed directly to the request-prompt start', () => {
- const input = at(1, 'turn/start', { turn: 1 })
- const invalidStart = {
- ...input,
- role: 'start' as const,
- location: { kind: 'session' as const },
- }
- expect(() => requestPromptDefinition(inspectRequestPrompt).start({} as never, invalidStart, {} as never))
- .toThrow('request-prompt start requires request/header')
- })
- it('pins the system-message Definition edges the engine cannot reach', () => {
- const input = at(1, 'turn/start', { turn: 1 })
- const invalidStart = {
- ...input,
- role: 'start' as const,
- location: { kind: 'session' as const },
- }
- const state = { seq: 1, time: 1, turn: 1, step: 1, text: '# System', update: false }
- expect(systemMessageDefinition(inspectSystemPrompt).match(invalidStart.event)).toBeNull()
- expect(systemMessageDefinition(inspectSystemPrompt).update({ state } as never, invalidStart)).toBe(state)
- })
- it('keeps ordinary command-only history inactive for the Conversation shell', () => {
- const value = assembler([
- at(1, 'command/run', {
- commandId: 'command-1',
- name: 'help',
- source: { kind: 'user' },
- }),
- at(2, 'command/done', {
- commandId: 'command-1',
- kind: 'success',
- }),
- ])
- const current = snapshot(value)
- expect(current.order).toHaveLength(1)
- expect(current.nodes.get(current.order[0] ?? '')?.kind).toBe('command')
- expect(chatViewDefinition.isActive?.(current)).toBe(false)
- })
- it('keeps the Turn rail projection current when a chunk updates one node in place', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'user/message', textMessage('user-1', 'navigate here'), { surfaceOp: 'append' }),
- at(3, 'step/start', { turn: 1, step: 1 }),
- at(4, 'assistant/live-chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'first' },
- }),
- ])
- const opening = snapshot(value).navigation.items()
- expect(opening).toHaveLength(1)
- expect(opening[0]?.turn).toBe(1)
- expect(opening[0]?.prompt).toBe('navigate here')
- expect(opening[0]?.response).toBe('first')
- // Content-only upsert: the node keeps its key, so the rail's preview has to
- // follow the in-place update rather than the last structural publication.
- value.append(at(5, 'assistant/live-chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'text-delta', index: 0, text: ' and more' },
- }))
- value.flush()
- const streamed = snapshot(value).navigation.items()
- expect(streamed[0]?.response).toBe('first and more')
- expect(streamed).not.toBe(opening)
- })
- it('bounds each rail preview at its card budget instead of copying the whole transcript', () => {
- const long = 'x'.repeat(400)
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'user/message', textMessage('user-1', long), { surfaceOp: 'append' }),
- ])
- const items = snapshot(value).navigation.items()
- // One clipped prompt line: 49 characters plus the trailing ellipsis.
- expect(items[0]?.prompt.length).toBe(50)
- expect(items[0]?.prompt.endsWith('…')).toBe(true)
- })
- it('classifies reply content separately from reasoning and Tool protocol blocks', () => {
- expect(hasAssistantReplyContent([{ kind: 'text', text: ' ' }])).toBe(false)
- expect(hasAssistantReplyContent([{ kind: 'reasoning', text: 'thinking' }])).toBe(false)
- expect(hasAssistantReplyContent([{ kind: 'tool-call', callId: 'c', name: 'read', argsRaw: '{}' }])).toBe(false)
- expect(hasAssistantReplyContent([{ kind: 'text', text: 'answer' }])).toBe(true)
- expect(hasAssistantReplyContent([{ kind: 'image', attachment: {} as never }])).toBe(true)
- expect(hasAssistantReplyContent([{ kind: 'other', block: { type: 'future' } }])).toBe(true)
- })
- it('projects one reversible process window before the finalized answer', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'user/message', {
- ...textMessage('context-1', 'workspace context'),
- turn: 1,
- step: 1,
- source: { kind: 'plugin', plugin: 'context' },
- }, { surfaceOp: 'append' }),
- at(4, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
- }),
- at(5, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'checking' },
- }),
- at(6, 'assistant/live-chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'read', argumentsDelta: '{}' },
- }),
- ])
- const process = () => snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
- expect(process()).toMatchObject({ processStartSeq: 4, answerAnchorSeq: null, answerStep: null })
- expect(node(snapshot(value), 'turn-process')?.data).toMatchObject({ answerAnchorSeq: null })
- value.append(at(7, 'tool/call', {
- turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}',
- }))
- value.append(at(8, 'tool/result', {
- turn: 1, step: 1, message: toolResult('call-1', 'done'),
- }, { surfaceOp: 'append' }))
- value.append(at(9, 'step/end', { turn: 1, step: 1 }))
- value.append(at(10, 'step/start', { turn: 1, step: 2 }))
- value.append(at(11, 'assistant/live-chunk', {
- turn: 1, step: 2, chunk: { type: 'reasoning-delta', index: 0, text: 'final thinking' },
- }))
- value.append(at(12, 'assistant/live-chunk', {
- turn: 1, step: 2, chunk: { type: 'text-delta', index: 1, text: 'final reply' },
- }))
- value.flush()
- expect(process()).toMatchObject({
- processStartSeq: 4,
- answerAnchorSeq: null,
- answerStep: null,
- inlineReasoning: false,
- })
- value.append(at(13, 'llm/retry', {
- retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal',
- policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
- failure: { code: 'TRANSPORT', message: 'temporary' },
- }))
- value.flush()
- expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null })
- value.append(at(14, 'assistant/live-chunk', {
- turn: 1,
- step: 2,
- chunk: { type: 'text-delta', index: 0, text: 'replacement reply' },
- }))
- value.flush()
- expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null })
- value.append(at(15, 'step/end', { turn: 1, step: 2 }))
- value.append(at(16, 'turn/end', {
- turn: 1,
- reason: { kind: 'aborted', reason: { kind: 'user' } },
- }))
- value.flush()
- expect(process()).toMatchObject({ answerAnchorSeq: 14.1, answerStep: 2 })
- const recovered = assembler([
- at(20, 'turn/start', { turn: 2 }),
- at(21, 'step/start', { turn: 2, step: 1 }),
- at(22, 'assistant/message', {
- turn: 2, step: 1, message: assistantMessage('recovered-1', 'settled reply'),
- }, { surfaceOp: 'append' }),
- at(23, 'step/end', { turn: 2, step: 1 }),
- at(24, 'step/start', { turn: 2, step: 2 }),
- at(25, 'assistant/live-chunk', {
- turn: 2, step: 2, chunk: { type: 'text-delta', index: 0, text: 'crash partial' },
- }),
- at(26, 'turn/end', { turn: 2, reason: { kind: 'interrupted' } }),
- ])
- const recoveredProcess = snapshot(recovered).timeline.turns.get(2)?.data.get('turn-process')
- expect(recoveredProcess)
- .toMatchObject({ answerStep: 2, answerAnchorSeq: 25.1 })
- const partialWindow = assembler([
- at(30, 'assistant/live-chunk', {
- turn: 3, step: 4, chunk: { type: 'text-delta', index: 0, text: 'loaded tail' },
- }),
- at(31, 'step/end', { turn: 3, step: 4 }),
- ], true)
- const partialProcess = snapshot(partialWindow).timeline.turns.get(3)?.data.get('turn-process')
- expect(partialProcess)
- .toMatchObject({ processStartSeq: 30.1, answerAnchorSeq: 30.1, answerStep: 4 })
- })
- it('counts Assistant messages, Tool calls, and subagent delegations per Turn', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/message', {
- turn: 1, step: 1, message: assistantMessage('message-1', 'checking'),
- }, { surfaceOp: 'append' }),
- at(4, 'tool/call', {
- turn: 1, step: 1, callId: 'call-read', name: 'read', arguments: '{}',
- }),
- at(5, 'tool/result', {
- turn: 1, step: 1, message: toolResult('call-read', 'read done'),
- }, { surfaceOp: 'append' }),
- at(6, 'tool/call', {
- turn: 1, step: 1, callId: 'call-subagent', name: 'subagent_fork', arguments: '{}',
- }),
- at(7, 'tool/result', {
- turn: 1, step: 1, message: toolResult('call-subagent', 'delegation done'),
- }, { surfaceOp: 'append' }),
- at(8, 'step/end', { turn: 1, step: 1 }),
- at(9, 'step/start', { turn: 1, step: 2 }),
- at(10, 'assistant/message', {
- turn: 1, step: 2, message: assistantMessage('message-2', 'final answer'),
- }, { surfaceOp: 'append' }),
- at(11, 'step/end', { turn: 1, step: 2 }),
- at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ])
- const process = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
- expect(process).toMatchObject({
- messageCount: 1,
- toolCallCount: 1,
- subagentCount: 1,
- })
- })
- it('orders the opening User before its process control and later steering', () => {
- const steering = textMessage('steer-1', 'change direction')
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'user/message', {
- ...textMessage('context-1', 'runtime context'),
- source: { kind: 'plugin', plugin: 'context' },
- }, { surfaceOp: 'append' }),
- at(3, 'user/message', textMessage('user-1', 'question'), { surfaceOp: 'append' }),
- at(4, 'step/start', { turn: 1, step: 1 }),
- ])
- const opening = snapshot(value)
- expect(opening.order.map(key => opening.nodes.get(key)?.kind)).toEqual([
- 'user', 'context',
- ])
- value.append(at(5, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
- }))
- value.flush()
- const running = snapshot(value)
- expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([
- 'user', 'turn-process', 'context', 'assistant-step',
- ])
- value.append(at(6, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, inserted: [steering],
- }))
- value.append(at(7, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, removedCount: 1, inserted: [],
- }))
- value.append(at(8, 'user/message', steering, { surfaceOp: 'append' }))
- value.append(at(9, 'step/end', { turn: 1, step: 1 }))
- value.append(at(10, 'step/start', { turn: 1, step: 2 }))
- value.append(at(11, 'assistant/message', {
- turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
- }, { surfaceOp: 'append' }))
- value.append(at(12, 'step/end', { turn: 1, step: 2 }))
- value.append(at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
- value.flush()
- const current = snapshot(value)
- expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
- 'user', 'turn-process', 'context', 'steering', 'assistant-step', 'assistant-step', 'turn-tail',
- ])
- })
- it('replays pending splice chains and scopes steering to the current claim', () => {
- const first = textMessage('claim-first', 'first')
- const second = textMessage('claim-second', 'second')
- const canceled = textMessage('claim-canceled', 'canceled')
- const requeued = textMessage('claim-requeued', 'requeued')
- const later = textMessage('claim-later', 'later')
- const current = snapshot(assembler([
- at(1, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, inserted: [first],
- }),
- at(2, 'agent/inbox/spliced', {
- target: 'next-step', start: 1, inserted: [canceled],
- }),
- at(3, 'agent/inbox/spliced', {
- target: 'next-step', start: 1, inserted: [second],
- }),
- at(4, 'agent/inbox/spliced', {
- target: 'next-step', start: 2, removedCount: 1, inserted: [], outcome: 'canceled',
- }),
- at(5, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, removedCount: 2, inserted: [],
- }),
- at(6, 'user/message', first, { surfaceOp: 'append' }),
- at(7, 'user/message', second, { surfaceOp: 'append' }),
- at(8, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, inserted: [requeued],
- }),
- at(9, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, removedCount: 1, inserted: [],
- }),
- at(10, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, inserted: [requeued],
- }),
- at(11, 'user/message', requeued, { surfaceOp: 'append' }),
- at(12, 'user/message', canceled, { surfaceOp: 'append' }),
- at(13, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
- }),
- at(14, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, inserted: [later],
- }),
- at(15, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, removedCount: 1, inserted: [],
- }),
- at(16, 'user/message', later, { surfaceOp: 'append' }),
- ]))
- expect(current.order.map(key => current.nodes.get(key)).filter(node =>
- node?.kind === 'user' || node?.kind === 'steering')).toMatchObject([
- { kind: 'steering', data: { seq: 6 } },
- { kind: 'steering', data: { seq: 7 } },
- { kind: 'user', data: { seq: 11 } },
- { kind: 'user', data: { seq: 12 } },
- { kind: 'steering', data: { seq: 16 } },
- ])
- })
- it('orders a command-started Turn first steering before its process control', () => {
- const steering = textMessage('command-task', 'plan this change')
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, inserted: [steering],
- }),
- at(3, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, removedCount: 1, inserted: [],
- }),
- at(4, 'user/message', steering, { surfaceOp: 'append' }),
- at(5, 'step/start', { turn: 1, step: 1 }),
- at(6, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
- }),
- at(7, 'step/end', { turn: 1, step: 1 }),
- at(8, 'step/start', { turn: 1, step: 2 }),
- at(9, 'assistant/message', {
- turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
- }, { surfaceOp: 'append' }),
- at(10, 'step/end', { turn: 1, step: 2 }),
- at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ])
- const current = snapshot(value)
- expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
- 'steering', 'turn-process', 'assistant-step', 'assistant-step', 'turn-tail',
- ])
- })
- it('keeps a first human message after process evidence at its event position', () => {
- const steering = textMessage('late-steering', 'change direction')
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'tool/call', {
- turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}',
- }),
- at(4, 'tool/result', {
- turn: 1, step: 1, message: toolResult('call-1', 'done'),
- }, { surfaceOp: 'append' }),
- at(5, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, inserted: [steering],
- }),
- at(6, 'agent/inbox/spliced', {
- target: 'next-step', start: 0, removedCount: 1, inserted: [],
- }),
- at(7, 'user/message', steering, { surfaceOp: 'append' }),
- at(8, 'step/end', { turn: 1, step: 1 }),
- at(9, 'step/start', { turn: 1, step: 2 }),
- at(10, 'assistant/message', {
- turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
- }, { surfaceOp: 'append' }),
- at(11, 'step/end', { turn: 1, step: 2 }),
- at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ])
- const current = snapshot(value)
- expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
- 'turn-process', 'tool-call', 'steering', 'assistant-step', 'turn-tail',
- ])
- })
- it('keeps Process before pre-User Context as answer eligibility changes', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'user/message', {
- ...textMessage('context-1', 'runtime context'),
- source: { kind: 'plugin', plugin: 'context' },
- }, { surfaceOp: 'append' }),
- at(3, 'step/start', { turn: 1, step: 1 }),
- at(4, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
- }),
- ])
- const running = snapshot(value)
- expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([
- 'turn-process', 'context', 'assistant-step',
- ])
- value.append(at(5, 'step/end', { turn: 1, step: 1 }))
- value.append(at(6, 'step/start', { turn: 1, step: 2 }))
- value.append(at(7, 'assistant/message', {
- turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
- }, { surfaceOp: 'append' }))
- value.flush()
- const answered = snapshot(value)
- expect(answered.order.map(key => answered.nodes.get(key)?.kind)).toEqual([
- 'turn-process', 'context', 'assistant-step', 'assistant-step',
- ])
- value.append(at(8, 'llm/retry', {
- retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal',
- policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
- failure: { code: 'TRANSPORT', message: 'temporary' },
- }))
- value.flush()
- const retried = snapshot(value)
- expect(retried.order.map(key => retried.nodes.get(key)?.kind)).toEqual([
- 'turn-process', 'context', 'assistant-step', 'model-retry',
- ])
- })
- it('establishes the answer boundary only when a streamed answer finalizes', () => {
- const value = assembler([
- at(40, 'turn/start', { turn: 4 }),
- at(41, 'step/start', { turn: 4, step: 1 }),
- at(42, 'assistant/live-chunk', {
- turn: 4, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
- }),
- at(43, 'assistant/live-chunk', {
- turn: 4, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' },
- }),
- ])
- const read = () => {
- const process = snapshot(value).timeline.turns.get(4)?.data.get('turn-process')
- if (process === undefined) throw new Error('turn-process data is unavailable')
- return process
- }
- const streamingNode = node(snapshot(value), 'assistant-step')
- if (streamingNode === undefined) throw new Error('streaming Assistant node is unavailable')
- const processSource = snapshot(value).nodes.processSource(streamingNode.key)
- let processNotifications = 0
- processSource.subscribe(() => { processNotifications++ })
- const streaming = read()
- value.append(at(44, 'assistant/message', {
- turn: 4, step: 1, message: assistantMessage('settled-4', 'answer'),
- }, { surfaceOp: 'append' }))
- value.flush()
- const settled = read()
- expect(streaming).toMatchObject({ answerAnchorSeq: null, answerStep: null })
- expect(settled.answerAnchorSeq).toBe(44)
- expect(settled.answerStep).toBe(1)
- expect(processNotifications).toBe(1)
- expect(processSource.getSnapshot()?.spec.answerAnchorSeq).toBe(44)
- })
- it('reuses the open Turn-process projection across continuing Assistant chunks', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'first' },
- }),
- ])
- const before = snapshot(value)
- const processNode = node(before, 'turn-process')
- const processData = before.timeline.turns.get(1)?.data.get('turn-process')
- const assistantNode = node(before, 'assistant-step')
- if (assistantNode === undefined) throw new Error('Assistant node is unavailable')
- const processSource = before.nodes.processSource(assistantNode.key)
- const processPresentation = processSource.getSnapshot()
- let processNotifications = 0
- processSource.subscribe(() => { processNotifications++ })
- value.append(at(4, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: ' second' },
- }))
- value.flush()
- const after = snapshot(value)
- expect(after.timeline.turns.get(1)?.data.get('turn-process')).toBe(processData)
- expect(node(after, 'turn-process')).toBe(processNode)
- expect(node(after, 'assistant-step')).not.toBe(assistantNode)
- expect(processSource.getSnapshot()).toBe(processPresentation)
- expect(processNotifications).toBe(0)
- })
- it('notifies process sources only for Nodes in the changed Turn', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/message', {
- turn: 1, step: 1, message: assistantMessage('answer-1', 'first answer'),
- }, { surfaceOp: 'append' }),
- at(4, 'step/end', { turn: 1, step: 1 }),
- at(5, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- at(6, 'turn/start', { turn: 2 }),
- at(7, 'step/start', { turn: 2, step: 1 }),
- at(8, 'assistant/live-chunk', {
- turn: 2, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
- }),
- ])
- const assistants = snapshot(value).nodes.values()
- .filter((candidate): candidate is ChatConversationViewNode & { data: AssistantChatData } => (
- candidate.kind === 'assistant-step'
- ))
- const first = assistants.find(candidate => candidate.data.turn === 1)
- const second = assistants.find(candidate => candidate.data.turn === 2)
- if (first === undefined || second === undefined) throw new Error('Assistant fixtures are unavailable')
- let firstNotifications = 0
- let secondNotifications = 0
- snapshot(value).nodes.processSource(first.key).subscribe(() => { firstNotifications++ })
- snapshot(value).nodes.processSource(second.key).subscribe(() => { secondNotifications++ })
- value.append(at(9, 'assistant/message', {
- turn: 2, step: 1, message: assistantMessage('answer-2', 'second answer'),
- }, { surfaceOp: 'append' }))
- value.append(at(10, 'step/end', { turn: 2, step: 1 }))
- value.append(at(11, 'turn/end', { turn: 2, reason: { kind: 'completed' } }))
- value.flush()
- expect(firstNotifications).toBe(0)
- expect(secondNotifications).toBe(1)
- })
- it('anchors a streamed non-text answer from its block start', () => {
- const value = assembler([
- at(50, 'turn/start', { turn: 5 }),
- at(51, 'step/start', { turn: 5, step: 1 }),
- at(52, 'assistant/live-chunk', {
- turn: 5, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'image' },
- }),
- ])
- const current = snapshot(value)
- const process = node(current, 'turn-process')
- const answer = node(current, 'assistant-step')
- const processData = current.timeline.turns.get(5)?.data.get('turn-process')
- expect(process?.anchorSeq).toBe(51.9)
- expect(answer?.anchorSeq).toBe(52)
- expect(processData)
- .toMatchObject({ answerAnchorSeq: null, answerStep: null })
- })
- it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/live-chunk', {
- turn: 1,
- step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'streaming' },
- }),
- ])
- const runningSnapshot = snapshot(value)
- const running = node(runningSnapshot, 'assistant-step')
- expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] })
- expect(running?.location.kind === 'step'
- ? running.location.step.data.get('assistant-step')
- : undefined).toBe(running?.data)
- const order = runningSnapshot.order
- value.append(at(4, 'assistant/message', {
- turn: 1,
- step: 1,
- message: assistantMessage('assistant-1', 'settled'),
- }, { surfaceOp: 'append' }))
- value.flush()
- const settledSnapshot = snapshot(value)
- const settled = node(settledSnapshot, 'assistant-step')
- expect(settled?.key).toBe(running?.key)
- expect(settledSnapshot.order).toBe(order)
- expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] })
- expect(settled?.location.kind === 'step'
- ? settled.location.step.data.get('assistant-step')
- : undefined).toBe(settled?.data)
- const interruptedValue = assembler([
- at(10, 'turn/start', { turn: 2 }),
- at(11, 'step/start', { turn: 2, step: 1 }),
- at(12, 'assistant/live-chunk', {
- turn: 2,
- step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'partial' },
- }),
- at(13, 'step/end', { turn: 2, step: 1 }),
- ])
- const interrupted = node(snapshot(interruptedValue), 'assistant-step')
- expect(interrupted?.data).toMatchObject({ status: 'interrupted' })
- expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
- const markedValue = assembler([
- at(20, 'turn/start', { turn: 3 }),
- at(21, 'step/start', { turn: 3, step: 1 }),
- at(22, 'assistant/message', {
- turn: 3,
- step: 1,
- message: assistantMessage('assistant-3', 'cut short'),
- interrupted: true,
- }, { surfaceOp: 'append' }),
- ])
- const marked = node(snapshot(markedValue), 'assistant-step')
- expect(marked?.data).toMatchObject({ status: 'interrupted', blocks: [{ kind: 'text', text: 'cut short' }] })
- expect((marked?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
- const hiddenValue = assembler([
- at(20, 'turn/start', { turn: 3 }),
- at(21, 'step/start', { turn: 3, step: 1 }),
- at(22, 'llm/retry', {
- retryId: 'retry-hidden',
- turn: 3,
- step: 1,
- provider: 'fake',
- mode: 'normal',
- policyKey: 'fake-normal',
- retry: 1,
- maxRetries: 2,
- delayMs: 10,
- failure: { code: 'TRANSPORT', message: 'temporary' },
- }),
- ])
- expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined()
- const toolOnlyValue = assembler([
- at(30, 'turn/start', { turn: 4 }),
- at(31, 'step/start', { turn: 4, step: 1 }),
- at(32, 'assistant/live-chunk', {
- turn: 4,
- step: 1,
- chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' },
- }),
- at(33, 'assistant/message', {
- turn: 4,
- step: 1,
- message: {
- ...assistantMessage('assistant-tool-only', ''),
- content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
- },
- }, { surfaceOp: 'append' }),
- ])
- const toolOnlySnapshot = snapshot(toolOnlyValue)
- expect(toolOnlySnapshot.order).toEqual([])
- expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden')
- expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{
- kind: 'assistant',
- seq: 33,
- timing: { firstTokenTime: 1_700_000_000_032 },
- }])
- const interruptedToolOnlyValue = assembler([
- at(35, 'turn/start', { turn: 5 }),
- at(36, 'step/start', { turn: 5, step: 1 }),
- at(37, 'assistant/live-chunk', {
- turn: 5,
- step: 1,
- chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
- }),
- at(38, 'step/end', { turn: 5, step: 1 }),
- ])
- const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step')
- expect(interruptedToolOnly?.visibility).toBe('visible')
- expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' })
- const retryTimingValue = assembler([
- at(50, 'turn/start', { turn: 6 }),
- at(51, 'step/start', { turn: 6, step: 1 }),
- at(52, 'assistant/live-chunk', {
- turn: 6,
- step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
- }),
- at(53, 'llm/retry', {
- retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal',
- policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
- failure: { code: 'TRANSPORT', message: 'temporary' },
- }),
- at(54, 'assistant/live-chunk', {
- turn: 6,
- step: 1,
- chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
- }),
- at(55, 'assistant/message', {
- turn: 6,
- step: 1,
- message: assistantMessage('assistant-retried', 'done'),
- }, { surfaceOp: 'append' }),
- ])
- const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode
- expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052)
- const partialWindow = assembler([
- at(40, 'assistant/live-chunk', {
- turn: 5,
- step: 2,
- chunk: { type: 'text-delta', index: 0, text: 'loaded partial' },
- }),
- at(41, 'step/end', { turn: 5, step: 2 }),
- ], true)
- const recovered = node(snapshot(partialWindow), 'assistant-step')
- expect(recovered?.data).toMatchObject({
- status: 'interrupted',
- blocks: [{ kind: 'text', text: 'loaded partial' }],
- })
- })
- it('omits first-token metrics after live settlement and after reopening the same history', () => {
- const attemptId = LlmAttemptId('settled-chat-timing')
- const starts = [
- at(1, 'turn/start', { turn: 1 }, { time: 1_000 }),
- at(2, 'step/start', { turn: 1, step: 1 }, { time: 1_010 }),
- ]
- const value = assembler(starts)
- const chunk = { type: 'text-delta' as const, index: 0, text: 'Answer' }
- value.append({
- type: 'transient',
- event: {
- type: 'assistant/live-chunk', seq: 2.5, time: 1_030,
- data: { attemptId, turn: 1, step: 1, chunk },
- },
- })
- value.flush()
- expect(node(snapshot(value), 'assistant-step')?.data).toMatchObject({
- status: 'running', time: 1_030, blocks: [{ kind: 'text', text: 'Answer' }],
- })
- const stream = new AssistantStreamAccumulator()
- stream.push({ time: 1_030, chunk })
- const event = at(3, 'assistant/message', {
- turn: 1, step: 1, message: assistantMessage('settled-timing', 'Answer'),
- stream: stream.snapshot(), usage: { outputTokens: 10 },
- }, { surfaceOp: 'append', time: 1_050 }).event
- if (event.type !== 'assistant/message') throw new Error('expected Assistant settlement')
- const settlement = { type: 'event' as const, event }
- value.settleAssistant(attemptId, settlement)
- const ends = [
- at(4, 'step/end', { turn: 1, step: 1 }, { time: 1_060 }),
- at(5, 'turn/end', { turn: 1, reason: { kind: 'completed' } }, { time: 1_070 }),
- ]
- for (const end of ends) value.append(end)
- value.flush()
- const reopened = assembler([...starts, settlement, ...ends])
- for (const current of [value, reopened]) {
- const view = snapshot(current)
- const assistant = (node(view, 'assistant-step')?.data as AssistantChatData).finalNode
- expect(assistant?.timing).toEqual({
- stepStartTime: 1_010, firstTokenTime: null, completedTime: 1_050,
- })
- const tail = node(view, 'turn-tail')?.data as TurnTailChatData
- expect(tail.turn).toBe(1)
- expect(tail.ttftMs).toBeUndefined()
- expect(tail.tokensPerSecond).toBeUndefined()
- }
- })
- it('uses live Assistant deltas without replaying settled embedded streams', () => {
- const runningHistory = [
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
- }, { time: 1_000 }),
- at(4, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
- }, { time: 1_000 }),
- at(5, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\t' },
- }, { time: 995 }),
- at(6, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' },
- }, { time: 1_004 }),
- at(7, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: '' },
- }),
- at(8, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' },
- }),
- at(9, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'ing' },
- }),
- at(10, 'assistant/live-chunk', {
- turn: 1, step: 1,
- chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '' },
- }),
- at(11, 'assistant/live-chunk', {
- turn: 1, step: 1,
- chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '{"x":' },
- }),
- at(12, 'assistant/live-chunk', {
- turn: 1, step: 1,
- chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', argumentsDelta: '1}' },
- }),
- ]
- const scalar = assembler(runningHistory)
- const packedHistory = packedInputs(runningHistory)
- expect(packedHistory).toHaveLength(3)
- const runningAttempt = packedHistory.at(-1)?.event
- expect(runningAttempt?.type).toBe('assistant/attempt')
- if (runningAttempt?.type !== 'assistant/attempt') throw new Error('expected packed running attempt')
- expect(runningAttempt.data.stream.length).toBeGreaterThan(0)
- const packed = assembler(packedHistory)
- const running = node(snapshot(scalar), 'assistant-step')
- expect(running?.data).toMatchObject({
- time: 1_004,
- blocks: [
- { kind: 'text', text: ' \tanswer' },
- { kind: 'reasoning', text: 'thinking' },
- { kind: 'tool-call', callId: 'call-1', name: '', argsRaw: '{"x":1}' },
- ],
- })
- expect(snapshot(packed).legacy.partial).toBeNull()
- expect(node(snapshot(packed), 'assistant-step')).toBeUndefined()
- for (const value of [scalar, packed]) {
- value.append(at(13, 'step/end', { turn: 1, step: 1 }))
- value.append(at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
- value.flush()
- }
- expect(node(snapshot(scalar), 'assistant-step')?.data).toMatchObject({ status: 'interrupted' })
- expect(node(snapshot(packed), 'assistant-step')).toBeUndefined()
- const partialHistory = [
- ...runningHistory.slice(2),
- at(13, 'step/end', { turn: 1, step: 1 }),
- at(14, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ]
- const partialPacked = snapshot(assembler(packedInputs(partialHistory), true))
- expect(partialPacked.legacy.partial).toBeNull()
- expect(node(partialPacked, 'assistant-step')).toBeUndefined()
- const finalizedHistory = [
- at(20, 'turn/start', { turn: 2 }),
- at(21, 'step/start', { turn: 2, step: 1 }),
- at(22, 'assistant/live-chunk', {
- turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
- }, { time: 2_000 }),
- at(23, 'assistant/live-chunk', {
- turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' ' },
- }, { time: 1_999 }),
- at(24, 'assistant/live-chunk', {
- turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' },
- }, { time: 2_000 }),
- at(25, 'llm/retry', {
- retryId: 'packed-retry', turn: 2, step: 1, provider: 'fake', mode: 'normal',
- policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
- failure: { code: 'TRANSPORT', message: 'temporary' },
- }),
- at(26, 'assistant/live-chunk', {
- turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: '' },
- }),
- at(27, 'assistant/live-chunk', {
- turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'second' },
- }),
- at(28, 'assistant/live-chunk', {
- turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: ' attempt' },
- }),
- at(29, 'assistant/message', {
- turn: 2, step: 1, message: assistantMessage('packed-final', 'done'),
- }, { surfaceOp: 'append' }),
- ]
- const finalizedInputs = packedInputs(finalizedHistory)
- expect(finalizedInputs.filter(input => input.event.type === 'assistant/attempt')).toHaveLength(1)
- const finalizedMessage = finalizedInputs.find(input => input.event.type === 'assistant/message')?.event
- if (finalizedMessage?.type !== 'assistant/message') throw new Error('expected packed final message')
- expect(finalizedMessage.data.stream.length).toBeGreaterThan(0)
- const finalizedPacked = snapshot(assembler(finalizedInputs))
- const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
- expect(finalNode).toMatchObject({
- blocks: [{ kind: 'text', text: 'done' }],
- timing: { firstTokenTime: null },
- })
- const namedToolHistory = [
- at(40, 'turn/start', { turn: 3 }),
- at(41, 'step/start', { turn: 3, step: 1 }),
- ...[42, 43, 44].map(seq => at(seq, 'assistant/live-chunk', {
- turn: 3, step: 1,
- chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
- }, { time: 4_000 + seq - 42 })),
- at(45, 'assistant/message', {
- turn: 3,
- step: 1,
- message: {
- ...assistantMessage('named-tool-final', ''),
- content: [{ type: 'tool-call', id: 'call-2', name: 'read', arguments: '' }],
- },
- }, { surfaceOp: 'append' }),
- ]
- const namedToolInputs = packedInputs(namedToolHistory)
- const namedToolMessage = namedToolInputs.find(input => input.event.type === 'assistant/message')?.event
- if (namedToolMessage?.type !== 'assistant/message') throw new Error('expected packed named-tool message')
- expect(namedToolMessage.data.stream.length).toBeGreaterThan(0)
- const namedToolPacked = snapshot(assembler(namedToolInputs))
- const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
- expect(namedTool).toMatchObject({
- blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
- timing: { firstTokenTime: null },
- })
- })
- it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }),
- ])
- const runningSnapshot = snapshot(value)
- const running = node(runningSnapshot, 'tool-call')
- expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' })
- const order = runningSnapshot.order
- value.append(at(4, 'tool/result', {
- turn: 1,
- step: 1,
- message: toolResult('root', 'done', true),
- error: { name: 'ToolError', code: 'failed' },
- meta: { presentation: 'raw' },
- }, { surfaceOp: 'append' }))
- value.flush()
- const settledSnapshot = snapshot(value)
- const settled = node(settledSnapshot, 'tool-call')
- expect(settled?.key).toBe(running?.key)
- expect(settledSnapshot.order).toBe(order)
- expect((settled?.data as ToolChatData).root).toMatchObject({
- kind: 'tool-result',
- callId: 'root',
- call: { name: 'code', argsRaw: '{}' },
- content: [{ type: 'text', text: 'done' }],
- isError: true,
- error: { name: 'ToolError', code: 'failed' },
- meta: { presentation: 'raw' },
- })
- const history = assembler([
- at(14, 'tool/ptc-dispatch-start', {
- rootCallId: 'history-root',
- parentCallId: 'history-root',
- subCallId: 'child',
- name: 'read',
- arguments: { path: 'README.md' },
- }),
- at(15, 'tool/ptc-dispatch', {
- rootCallId: 'history-root',
- parentCallId: 'history-root',
- subCallId: 'child',
- name: 'read',
- arguments: { path: 'README.md' },
- isError: true,
- error: { name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED', reason: 'blocked' },
- content: [{ type: 'text', text: 'contents' }],
- }),
- at(16, 'tool/result', {
- turn: 2,
- step: 1,
- message: toolResult('history-root', 'root done'),
- }, { surfaceOp: 'append' }),
- ], true)
- const before = node(snapshot(history), 'tool-call')
- expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
- {
- kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' },
- error: { name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED', reason: 'blocked' },
- },
- ])
- history.prepend([
- at(10, 'turn/start', { turn: 2 }),
- at(11, 'step/start', { turn: 2, step: 1 }),
- at(13, 'tool/call', {
- turn: 2,
- step: 1,
- callId: 'history-root',
- name: 'code',
- arguments: '{}',
- }),
- ], false)
- history.flush()
- const after = node(snapshot(history), 'tool-call')
- expect(after?.key).toBe(before?.key)
- expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
- {
- kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' },
- error: { name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED', reason: 'blocked' },
- },
- ])
- const firstChild = (after?.data as ToolChatData).root.subCalls[0]
- history.append(at(17, 'tool/ptc-dispatch-start', {
- rootCallId: 'history-root',
- parentCallId: 'history-root',
- subCallId: 'second-child',
- name: 'write',
- arguments: { path: 'out.txt' },
- }))
- history.flush()
- const withSecondChild = node(snapshot(history), 'tool-call')
- expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild)
- })
- it('joins mixed historical and current subcall IDs by explicit fields through replay', () => {
- const historicalId = 'other-root:code:1'
- const currentId = 'other-root:ptc:2'
- const historical = {
- rootCallId: 'root', parentCallId: 'root', subCallId: historicalId,
- name: 'run_code', arguments: {},
- }
- const current = {
- rootCallId: 'root', parentCallId: historicalId, subCallId: currentId,
- name: 'read', arguments: { file_path: 'README.md' },
- }
- const events = [
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'run_code', arguments: '{}' }),
- at(4, 'tool/call', { turn: 1, step: 1, callId: 'other-root', name: 'run_code', arguments: '{}' }),
- at(5, 'tool/ptc-dispatch-start', historical),
- at(6, 'tool/ptc-dispatch-start', current),
- at(7, 'tool/ptc-dispatch', { ...current, isError: false, content: [{ type: 'text', text: 'contents' }] }),
- at(8, 'tool/ptc-dispatch', { ...historical, isError: false, content: [] }),
- ]
- const expected = {
- callId: 'root',
- subCalls: [{
- kind: 'tool-result', callId: historicalId, parentCallId: 'root', callTime: events[4]!.event.time,
- subCalls: [{
- kind: 'tool-result', callId: currentId, parentCallId: historicalId, callTime: events[5]!.event.time,
- content: [{ type: 'text', text: 'contents' }], subCalls: [],
- }],
- }],
- }
- const live = assembler(events.slice(0, 4))
- for (const event of events.slice(4)) live.append(event)
- live.flush()
- const replay = assembler(events.slice(4), true)
- replay.prepend(events.slice(0, 4), false)
- replay.flush()
- for (const value of [live, replay]) {
- const view = snapshot(value)
- const roots = view.order.flatMap((key) => {
- const entry = view.nodes.get(key)
- return entry?.kind === 'tool-call' ? [(entry.data as ToolChatData).root] : []
- })
- expect(roots).toHaveLength(2)
- expect(roots.find(root => root.callId === 'root')).toMatchObject(expected)
- expect(roots.find(root => root.callId === 'other-root')?.subCalls).toEqual([])
- }
- })
- it('prepends an older turn without replacing already materialized nodes', () => {
- const value = assembler([
- at(20, 'turn/start', { turn: 2 }),
- at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }),
- at(22, 'step/start', { turn: 2, step: 1 }),
- at(23, 'assistant/message', {
- turn: 2,
- step: 1,
- message: assistantMessage('newer-assistant', 'newer answer'),
- }, { surfaceOp: 'append' }),
- at(24, 'step/end', { turn: 2, step: 1 }),
- at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }),
- ], true)
- const before = snapshot(value)
- const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '')
- const store = before.nodes
- value.prepend([
- at(10, 'turn/start', { turn: 1 }),
- at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }),
- at(12, 'step/start', { turn: 1, step: 1 }),
- at(13, 'assistant/message', {
- turn: 1,
- step: 1,
- message: assistantMessage('older-assistant', 'older answer'),
- }, { surfaceOp: 'append' }),
- at(14, 'step/end', { turn: 1, step: 1 }),
- at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ], false)
- value.flush()
- const after = snapshot(value)
- expect(after.nodes).toBe(store)
- expect(after.nodes.get(existing?.key ?? '')).toBe(existing)
- expect(after.order).toHaveLength(before.order.length + 4)
- expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
- 'user', 'turn-process', 'assistant-step', 'turn-tail',
- 'user', 'turn-process', 'assistant-step', 'turn-tail',
- ])
- })
- it('appends a later turn without replacing nodes from the completed turn', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
- at(3, 'step/start', { turn: 1, step: 1 }),
- at(4, 'assistant/message', {
- turn: 1,
- step: 1,
- message: assistantMessage('first-assistant', 'first answer'),
- }, { surfaceOp: 'append' }),
- at(5, 'step/end', { turn: 1, step: 1 }),
- at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ])
- const before = snapshot(value)
- const oldOrder = before.order
- const oldNodes = oldOrder.map(key => before.nodes.get(key))
- value.append(at(7, 'turn/start', { turn: 2 }))
- value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }))
- value.flush()
- const after = snapshot(value)
- expect(after.nodes).toBe(before.nodes)
- expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder)
- expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes)
- expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
- 'user', 'turn-process', 'assistant-step', 'turn-tail', 'user',
- ])
- })
- it('keeps branching unavailable when a tool result follows the closing Assistant', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/message', {
- turn: 1,
- step: 1,
- message: assistantMessage('assistant-before-tool', 'running a tool'),
- }, { surfaceOp: 'append' }),
- at(4, 'tool/call', { turn: 1, step: 1, callId: 'late-tool', name: 'read', arguments: '{}' }),
- at(5, 'tool/result', {
- turn: 1,
- step: 1,
- message: toolResult('late-tool', 'done'),
- }, { surfaceOp: 'append' }),
- at(6, 'step/end', { turn: 1, step: 1 }),
- at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ])
- const tail = node(snapshot(value), 'turn-tail')?.data as TurnTailChatData
- expect(tail.closing?.finalNode.seq).toBe(3)
- expect(tail.branchUnavailable).toBe(true)
- })
- it('publishes exact Turn usage only after pagination supplies the full lifecycle window', () => {
- const value = assembler([
- at(3, 'assistant/message', {
- turn: 1,
- step: 1,
- message: assistantMessage('usage-assistant', 'done'),
- usage: {
- inputTokens: 10,
- outputTokens: 4,
- totalTokens: 17,
- cacheReadTokens: 2,
- cacheWriteTokens: 1,
- reasoningTokens: 1,
- },
- }, { surfaceOp: 'append' }),
- at(4, 'step/end', { turn: 1, step: 1 }),
- at(5, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ], true)
- expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toBeUndefined()
- value.prepend([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- ], false)
- value.flush()
- expect((node(snapshot(value), 'turn-tail')?.data as TurnTailChatData).tokenUsage).toEqual({
- uncachedInputTokens: 10,
- outputTokens: 4,
- totalTokens: 17,
- cacheReadTokens: 2,
- cacheWriteTokens: 1,
- reasoningTokens: 1,
- routes: [{ provider: 'fake', model: 'fake' }],
- })
- })
- it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => {
- const value = assembler([
- at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }),
- ], true)
- const before = node(snapshot(value), 'user')
- expect(before).toBeDefined()
- value.prepend([
- at(1, 'agent/inbox/spliced', {
- target: 'next-step',
- start: 0,
- inserted: [textMessage('steer-1', 'change direction')],
- }),
- at(2, 'agent/inbox/spliced', {
- target: 'next-step',
- start: 0,
- removedCount: 1,
- inserted: [],
- }),
- ], false)
- value.flush()
- const after = node(snapshot(value), 'steering')
- expect(after?.key).toBe(before?.key)
- expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' })
- expect(node(snapshot(value), 'user')).toBeUndefined()
- })
- it('orders claimed steering after the finalized Turn tail', () => {
- const steering = textMessage('steer-after-answer', 'change direction')
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/message', {
- turn: 1,
- step: 1,
- message: assistantMessage('assistant-before-steering', 'initial answer'),
- }, { surfaceOp: 'append' }),
- at(4, 'agent/inbox/spliced', {
- target: 'next-step',
- start: 0,
- inserted: [steering],
- }),
- at(5, 'agent/inbox/spliced', {
- target: 'next-step',
- start: 0,
- removedCount: 1,
- inserted: [],
- }),
- at(6, 'user/message', steering, { surfaceOp: 'append' }),
- at(7, 'step/end', { turn: 1, step: 1 }),
- at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ])
- const current = snapshot(value)
- const steeringNode = node(current, 'steering')
- expect(steeringNode).toBeDefined()
- expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key)
- })
- it('classifies appended producer context from durable source metadata', () => {
- const value = assembler([
- at(1, 'user/message', {
- ...textMessage('skill-context', 'follow these instructions'),
- source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
- }, { surfaceOp: 'append' }),
- ])
- expect(node(snapshot(value), 'context')?.data).toMatchObject({
- kind: 'context',
- producer: { role: 'inject', label: 'demo-skill' },
- form: 'instructions',
- })
- })
- it('materializes series starts and system node replacements but not same-series config or tool changes', () => {
- const tools = [{ name: 'read', description: 'Read', parameters: { type: 'object' } }]
- const expandedTools = [...tools, { name: 'write', description: 'Write', parameters: { type: 'object' } }]
- const value = assembler([
- systemAt(1, '# Initial'),
- at(2, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' }, tools },
- }),
- at(3, 'request/header', {
- reason: 'change',
- header: { config: { provider: 'fake', model: 'fake' }, tools: expandedTools },
- }),
- at(4, 'request/header', {
- reason: 'change',
- header: { config: { provider: 'fake', model: 'fake', maxTokens: 1_024 }, tools: expandedTools },
- }),
- at(5, 'request/header', {
- reason: 'change',
- startsSeries: true,
- header: { config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, tools: expandedTools },
- }),
- at(6, 'request/header', {
- reason: 'resume',
- header: { config: { provider: 'fake', model: 'fake', maxTokens: 2_048 }, tools: expandedTools },
- }),
- systemAt(7, '# Updated', 1),
- at(8, 'request/header', {
- reason: 'change',
- header: { config: { provider: 'fake', model: 'fake', maxTokens: 4_096 }, tools: expandedTools },
- }),
- ])
- const current = snapshot(value)
- const prompts = current.nodes.values()
- .filter(candidate => candidate.kind === 'system-prompt')
- expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([
- { anchorSeq: 1, data: { text: '# Initial' } },
- { anchorSeq: 5, data: { text: '# Initial' } },
- { anchorSeq: 6, data: { text: '# Initial' } },
- { anchorSeq: 8, data: { text: '# Updated' } },
- ])
- expect(current.nodes.values().filter(candidate => candidate.kind === 'unknown')).toEqual([])
- })
- it('shows a complete appended prompt at the start of a headerless window', () => {
- const value = assembler([
- systemUpdateAt(10, '# Known prompt', 2, 1),
- at(11, 'user/message', textMessage('window-user', 'continue'), { surfaceOp: 'append' }),
- ], true)
- const current = snapshot(value)
- expect(current.nodes.values().filter(candidate => candidate.kind === 'system-prompt')
- .map(candidate => candidate.data)).toEqual([{ text: '# Known prompt' }])
- expect(current.nodes.values().filter(candidate => candidate.kind === 'unknown')).toEqual([])
- value.prepend([
- systemAt(1, '# Original'),
- at(2, 'request/header', { reason: 'initial', header: { config: { provider: 'fake', model: 'fake' } } }),
- ], false)
- value.flush()
- const restored = snapshot(value)
- expect(restored.order.map(key => restored.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt')
- .map(candidate => candidate?.data)).toEqual([{ text: '# Original' }, { text: '# Known prompt', update: true }])
- })
- it('withholds windowed replacement prompts until prepend resolves their positions', () => {
- const windowed = assembler([
- systemAt(10, '# Resumed prompt', 5),
- at(11, 'request/header', {
- reason: 'resume',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- ], true)
- const nodeless = assembler([
- at(11, 'request/header', {
- reason: 'resume',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- ], true)
- const systemless = assembler([
- at(20, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- ])
- expect(node(snapshot(windowed), 'system-prompt')).toBeUndefined()
- expect(node(snapshot(nodeless), 'system-prompt')).toBeUndefined()
- expect(node(snapshot(systemless), 'system-prompt')).toBeUndefined()
- const older = [
- systemAt(5, '# Original prompt'),
- at(6, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- ]
- const promptTexts = (value: ConversationNodeAssembler) => {
- const restored = snapshot(value)
- return restored.order.flatMap((key) => {
- const candidate = restored.nodes.get(key)
- return candidate?.kind === 'system-prompt' ? [candidate.data] : []
- })
- }
- windowed.prepend(older, false)
- windowed.flush()
- nodeless.prepend(older, false)
- nodeless.flush()
- expect(promptTexts(windowed)).toEqual([{ text: '# Original prompt' }, { text: '# Resumed prompt' }])
- expect(promptTexts(nodeless)).toEqual([{ text: '# Original prompt' }, { text: '# Original prompt' }])
- })
- it('shows the system node text as the request prompt card before the request messages', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- systemAt(3, '# System\n\nFollow instructions.'),
- at(4, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
- at(5, 'user/message', {
- ...textMessage('runtime-context', 'runtime facts'),
- source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt', form: 'snapshot' },
- }, { surfaceOp: 'append' }),
- at(6, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- ])
- const current = snapshot(value)
- expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
- 'system-prompt',
- 'user',
- 'context',
- ])
- expect(node(current, 'system-prompt')?.anchorSeq).toBe(1)
- expect(node(current, 'system-prompt')?.data).toEqual({ text: '# System\n\nFollow instructions.' })
- })
- it.each(['replay', 'live', 'partial'] as const)('restores A when compaction shadows B without a new system event (%s)', (mode) => {
- const history = [
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'system/message', { turn: 1, step: 1, message: systemMessage('A') }, { surfaceOp: 'append' }),
- at(4, 'request/header', {
- reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [] },
- }),
- at(5, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('a', 'a') }),
- at(6, 'step/end', { turn: 1, step: 1 }),
- at(7, 'step/start', { turn: 1, step: 2 }),
- at(8, 'system/message', { turn: 1, step: 2, message: systemMessage('B') }, { surfaceOp: 'append' }),
- at(9, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('b', 'b') }),
- at(10, 'step/end', { turn: 1, step: 2 }),
- at(11, 'step/start', { turn: 1, step: 3 }),
- at(12, 'user/message', {
- turn: 1, step: 3, id: 'summary', role: 'user',
- content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compaction' },
- }, { surfaceOp: { op: 'replace', startSeq: 5, endSeq: 9 }, sourceEventSeqs: [5, 8, 9] }),
- at(13, 'request/header', {
- reason: 'series', header: { config: { provider: 'test', model: 'test' }, tools: [] },
- }),
- at(14, 'assistant/message', { turn: 1, step: 3, message: assistantMessage('restored', 'restored') }),
- at(15, 'step/end', { turn: 1, step: 3 }),
- ]
- const value = assembler(mode === 'replay' ? history : [])
- if (mode === 'partial') {
- value.replaceWindow(history.slice(7), true)
- value.flush()
- expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt').map(candidate => candidate.data))
- .toEqual([{ text: 'B' }])
- value.prepend(history.slice(0, 7), false)
- value.flush()
- }
- if (mode === 'live') {
- for (const entry of history) {
- value.append(entry)
- value.flush()
- }
- }
- const current = snapshot(value)
- expect(current.order.map(key => current.nodes.get(key)).filter(candidate => candidate?.kind === 'system-prompt')
- .map(candidate => candidate?.data)).toEqual([
- { text: 'A' }, { text: 'B', update: true }, { text: 'A' },
- ])
- })
- it('withholds reversed unknown replacement endpoints and resolves them after prepend', () => {
- const value = assembler([
- systemAt(6, 'C', 3), systemAt(7, 'D', 5),
- at(8, 'request/header', { reason: 'resume', header: { config: { provider: 'test', model: 'test' } } }),
- ], true)
- expect(node(snapshot(value), 'system-prompt')).toBeUndefined()
- const uncertain = assembler([systemAt(6, 'C', 3), systemUpdateAt(7, 'Known but unordered', 1, 2)], true)
- expect(node(snapshot(uncertain), 'system-prompt')).toBeUndefined()
- value.prepend([systemAt(1, 'A'), systemAt(3, 'B'), systemAt(5, 'A2', 1)], false)
- value.flush()
- expect(snapshot(value).nodes.values().filter(candidate => candidate.kind === 'system-prompt')
- .map(candidate => candidate.data)).toEqual([{ text: 'A' }, { text: 'B', update: true }, { text: 'C' }])
- })
- it('never renders a system/message as a transcript bubble', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- systemAt(3, '# System'),
- at(4, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
- ])
- const current = snapshot(value)
- expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user'])
- value.append(systemAt(5, '# Replaced', 3))
- value.flush()
- const replaced = snapshot(value)
- expect(replaced.order.map(key => replaced.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user'])
- })
- it('presents an in-history prompt update as its own card and lets no same-step header repeat it', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- systemAt(3, '# System'),
- at(4, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
- at(5, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' }, tools: [] },
- }),
- at(6, 'step/end', { turn: 1, step: 1 }),
- at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- at(8, 'turn/start', { turn: 2 }),
- at(9, 'step/start', { turn: 2, step: 1 }),
- systemUpdateAt(10, '# Updated', 2, 1),
- at(11, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }),
- ])
- const cards = () => {
- const current = snapshot(value)
- return current.order.flatMap((key) => {
- const candidate = current.nodes.get(key)
- return candidate?.kind === 'system-prompt' ? [[candidate.anchorSeq, candidate.data]] : []
- })
- }
- // The update is the model-visible change at its position; node 0 keeps its card.
- expect(cards()).toEqual([
- [1, { text: '# System' }],
- [10, { text: '# Updated', update: true }],
- ])
- // A series header in the same step shows nothing more: the update card already carries the text.
- value.append(at(12, 'request/header', {
- reason: 'series',
- startsSeries: true,
- header: { config: { provider: 'fake', model: 'fake' }, tools: [] },
- }))
- value.flush()
- expect(cards()).toHaveLength(2)
- // A later series header presents the effective prompt again, as any series start does.
- value.append(at(13, 'step/end', { turn: 2, step: 1 }))
- value.append(at(14, 'step/start', { turn: 2, step: 2 }))
- value.append(at(15, 'request/header', {
- reason: 'series',
- startsSeries: true,
- header: { config: { provider: 'fake', model: 'fake' }, tools: [] },
- }))
- value.flush()
- expect(cards()).toEqual([
- [1, { text: '# System' }],
- [10, { text: '# Updated', update: true }],
- [14, { text: '# Updated' }],
- ])
- })
- it('renders no card for an in-history update that clears the prompt', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- systemAt(3, '# System'),
- at(4, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
- at(5, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' }, tools: [] },
- }),
- at(6, 'step/end', { turn: 1, step: 1 }),
- at(7, 'step/start', { turn: 1, step: 2 }),
- systemUpdateAt(8, '', 1, 2),
- ])
- const current = snapshot(value)
- expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual(['system-prompt', 'user'])
- })
- it('keeps the initial system prompt before the opening User as Turn process state changes', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- systemAt(3, '# System'),
- at(4, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
- at(5, 'user/message', {
- ...textMessage('runtime-context', 'runtime facts'),
- source: { kind: 'plugin', plugin: 'context' },
- }, { surfaceOp: 'append' }),
- at(6, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- ])
- const kinds = () => {
- const current = snapshot(value)
- return current.order.map(key => current.nodes.get(key)?.kind)
- }
- const promptKey = node(snapshot(value), 'system-prompt')?.key
- expect(kinds()).toEqual(['system-prompt', 'user', 'context'])
- value.append(at(7, 'assistant/live-chunk', {
- turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
- }))
- value.flush()
- expect(kinds()).toEqual([
- 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
- ])
- value.append(at(8, 'step/end', { turn: 1, step: 1 }))
- value.append(at(9, 'step/start', { turn: 1, step: 2 }))
- value.append(at(10, 'assistant/message', {
- turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
- }, { surfaceOp: 'append' }))
- value.append(at(11, 'step/end', { turn: 1, step: 2 }))
- value.append(at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
- value.flush()
- expect(kinds()).toEqual([
- 'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
- ])
- expect(node(snapshot(value), 'system-prompt')?.key).toBe(promptKey)
- })
- it('keeps an append-only later user turn in the existing system-prompt series', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- systemAt(3, '# System'),
- at(4, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
- at(5, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- at(6, 'step/end', { turn: 1, step: 1 }),
- at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- at(8, 'turn/start', { turn: 2 }),
- at(9, 'step/start', { turn: 2, step: 1 }),
- at(10, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }),
- ])
- const current = snapshot(value)
- const ordered = current.order.flatMap((key) => {
- const candidate = current.nodes.get(key)
- return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
- })
- expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user'])
- })
- it('places a withheld replacement prompt after prepend supplies its original node', () => {
- const reasons = ['change', 'resume', 'series'] as const
- for (const reason of reasons) {
- const windowedSystem = reason === 'series' ? '# Original' : '# Windowed'
- const windowed = assembler([
- at(6, 'turn/start', { turn: 2 }),
- at(7, 'step/start', { turn: 2, step: 1 }),
- systemAt(8, windowedSystem, 3),
- at(9, 'user/message', textMessage(`second-user-${reason}`, 'second'), { surfaceOp: 'append' }),
- at(10, 'request/header', {
- reason,
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- ], true)
- const before = snapshot(windowed)
- const prompt = node(before, 'system-prompt')
- const user = node(before, 'user')
- expect(prompt).toBeUndefined()
- if (user === undefined) throw new Error('windowed user fixture is incomplete')
- windowed.prepend([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- systemAt(3, '# Original'),
- at(4, 'user/message', textMessage(`first-user-${reason}`, 'first'), { surfaceOp: 'append' }),
- at(5, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- ], false)
- windowed.flush()
- const restored = snapshot(windowed)
- const prompts = restored.order.flatMap((key) => {
- const candidate = restored.nodes.get(key)
- return candidate?.kind === 'system-prompt' ? [candidate] : []
- })
- expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 10])
- expect(prompts.at(-1)?.data).toEqual({ text: windowedSystem })
- expect(restored.nodes.get(user.key)).toBeDefined()
- }
- })
- it('repeats an unchanged system prompt after a surface rewrite and before an explicit later series', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- systemAt(3, '# Same'),
- at(4, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
- at(5, 'request/header', {
- reason: 'initial',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- at(6, 'user/message', {
- ...textMessage('compacted', 'summary'),
- source: { kind: 'plugin', plugin: 'compact' },
- }, { surfaceOp: { op: 'replace', startSeq: 4, endSeq: 4 } }),
- at(7, 'request/header', {
- reason: 'series',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- at(8, 'step/end', { turn: 1, step: 1 }),
- at(9, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- at(10, 'turn/start', { turn: 2 }),
- at(11, 'step/start', { turn: 2, step: 1 }),
- at(12, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }),
- at(13, 'request/header', {
- reason: 'series',
- header: { config: { provider: 'fake', model: 'fake' } },
- }),
- ])
- const current = snapshot(value)
- const ordered = current.order.flatMap((key) => {
- const candidate = current.nodes.get(key)
- return candidate?.kind === 'system-prompt' || candidate?.kind === 'user' ? [candidate] : []
- })
- expect(ordered.map(candidate => candidate?.kind)).toEqual([
- 'system-prompt', 'user', 'system-prompt', 'system-prompt', 'user',
- ])
- expect(ordered.filter(candidate => candidate?.kind === 'system-prompt')
- .map(candidate => candidate?.anchorSeq)).toEqual([1, 7, 10])
- })
- it('associates each direct message with its immediately following session recall', () => {
- const value = assembler([
- at(1, 'user/message', textMessage('citing-research', '@Research notes what changed?'), { surfaceOp: 'append' }),
- at(2, 'user/message', {
- ...textMessage('research-context', 'snapshot'),
- source: {
- kind: 'session-reference',
- form: 'recall',
- version: 1,
- references: [{ sessionId: 'source-a', label: 'Research notes' }],
- },
- }, { surfaceOp: 'append' }),
- at(3, 'user/message', textMessage('citing-review', '@Review next'), { surfaceOp: 'append' }),
- at(4, 'user/message', {
- ...textMessage('review-context', 'snapshot'),
- source: {
- kind: 'session-reference',
- form: 'recall',
- version: 1,
- references: [{ sessionId: 'source-b', label: 'Review' }],
- },
- }, { surfaceOp: 'append' }),
- at(6, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }),
- ])
- const current = snapshot(value)
- const messages = [...current.nodes.values()]
- .filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
- const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user')
- expect(messages.map(candidate => candidate.kind)).toEqual(['user', 'context', 'user', 'context', 'user'])
- expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
- expect(users[1]?.data).toMatchObject({ referenceLabels: ['Review'] })
- expect(users[2]?.data).not.toHaveProperty('referenceLabels')
- })
- it('updates an already published direct node when its following recall arrives', () => {
- const value = assembler([
- at(1, 'user/message', textMessage('citing-user', '@Research notes what changed?'), { surfaceOp: 'append' }),
- ])
- const before = node(snapshot(value), 'user')
- expect(before?.data).not.toHaveProperty('referenceLabels')
- value.append(at(2, 'user/message', {
- ...textMessage('reference-context', 'snapshot'),
- source: {
- kind: 'session-reference',
- form: 'recall',
- version: 1,
- references: [{ sessionId: 'source-a', label: 'Research notes' }],
- },
- }, { surfaceOp: 'append' }))
- value.flush()
- const current = snapshot(value)
- const nodes = [...current.nodes.values()]
- .filter(candidate => candidate.kind === 'user' || candidate.kind === 'context')
- expect(nodes.map(candidate => candidate.kind)).toEqual(['user', 'context'])
- expect(nodes[0]?.key).toBe(before?.key)
- expect(nodes[0]?.data).toMatchObject({ referenceLabels: ['Research notes'] })
- expect(current.legacy.nodes[0]).toMatchObject({ referenceLabels: ['Research notes'] })
- })
- it('associates a claimed steering message with its following recall', () => {
- const steering = textMessage('steering-reference', '@Research notes continue')
- const value = assembler([
- at(1, 'agent/inbox/spliced', {
- target: 'next-step',
- start: 0,
- inserted: [steering],
- }),
- at(2, 'agent/inbox/spliced', {
- target: 'next-step',
- start: 0,
- removedCount: 1,
- inserted: [],
- }),
- at(3, 'user/message', steering, { surfaceOp: 'append' }),
- at(4, 'user/message', {
- ...textMessage('steering-reference-context', 'snapshot'),
- source: {
- kind: 'session-reference',
- form: 'recall',
- version: 1,
- references: [{ sessionId: 'source-a', label: 'Research notes' }],
- },
- }, { surfaceOp: 'append' }),
- ])
- expect(node(snapshot(value), 'steering')?.data).toMatchObject({
- messageId: 'steering-reference',
- referenceLabels: ['Research notes'],
- })
- })
- it('associates a direct message with the skill invocations injected for its step', () => {
- const skillInvocation = (id: string) => ({
- ...textMessage(id, 'instructions'),
- source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
- })
- const instructions = (id: string) => ({
- ...textMessage(id, 'workspace rules'),
- source: { kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }] },
- })
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }),
- at(3, 'step/start', { turn: 1, step: 1 }),
- at(4, 'user/message', instructions('rules-1'), { surfaceOp: 'append' }),
- at(5, 'user/message', skillInvocation('skill-body'), { surfaceOp: 'append' }),
- at(6, 'assistant/message', {
- turn: 1,
- step: 1,
- message: assistantMessage('answer-1', 'done'),
- }, { surfaceOp: 'append' }),
- at(7, 'step/end', { turn: 1, step: 1 }),
- at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- at(9, 'turn/start', { turn: 2 }),
- at(10, 'user/message', textMessage('later', '/demo-skill again?'), { surfaceOp: 'append' }),
- at(11, 'step/start', { turn: 2, step: 1 }),
- at(12, 'user/message', instructions('rules-2'), { surfaceOp: 'append' }),
- ])
- const users = [...snapshot(value).nodes.values()].filter(candidate => candidate.kind === 'user')
- expect(users).toHaveLength(2)
- expect(users[0]?.data).toMatchObject({ skillNames: ['demo-skill'] })
- expect(users[1]?.data).not.toHaveProperty('skillNames')
- })
- it('updates an already published direct node when its skill injection arrives', () => {
- const value = assembler([
- at(1, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }),
- ])
- const before = node(snapshot(value), 'user')
- expect(before?.data).not.toHaveProperty('skillNames')
- value.append(at(2, 'user/message', {
- ...textMessage('skill-body', 'instructions'),
- source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
- }, { surfaceOp: 'append' }))
- value.flush()
- const after = node(snapshot(value), 'user')
- expect(after?.key).toBe(before?.key)
- expect(after?.data).toMatchObject({ skillNames: ['demo-skill'] })
- })
- it('keeps replacement copies out of Chat business nodes', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'user/message', {
- ...textMessage('replacement-user', 'model-only context'),
- source: { kind: 'plugin', plugin: 'foreign' },
- }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 1 } }),
- at(4, 'assistant/message', {
- turn: 1,
- step: 1,
- message: assistantMessage('replacement-assistant', 'rewritten answer'),
- }, { surfaceOp: { op: 'replace', startSeq: 2, endSeq: 2 } }),
- at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }),
- at(6, 'tool/result', {
- turn: 1,
- step: 1,
- message: toolResult('root', 'pruned result'),
- }, { surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 } }),
- ])
- const current = snapshot(value)
- expect(node(current, 'user')).toBeUndefined()
- expect(node(current, 'context')).toBeUndefined()
- expect(node(current, 'assistant-step')).toBeUndefined()
- expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind')
- })
- it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => {
- const retry = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'llm/retry', {
- retryId: 'retry-1',
- turn: 1,
- step: 1,
- provider: 'fake',
- mode: 'normal',
- policyKey: 'fake-normal',
- retry: 1,
- maxRetries: 2,
- delayMs: 10,
- failure: { code: 'TRANSPORT', message: 'first' },
- }),
- at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }),
- at(5, 'llm/retry', {
- retryId: 'retry-1',
- turn: 1,
- step: 1,
- provider: 'fake',
- mode: 'normal',
- policyKey: 'fake-normal',
- retry: 2,
- maxRetries: 2,
- delayMs: 20,
- failure: { code: 'TRANSPORT', message: 'second' },
- }),
- at(6, 'step/end', { turn: 1, step: 1 }),
- at(7, 'turn/end', {
- turn: 1,
- reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
- }),
- ])
- const retryNode = node(snapshot(retry), 'model-retry')
- const retryData = retryNode?.data as RetryChatData
- expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled'])
- expect(node(snapshot(retry), 'turn-error')?.data).toMatchObject({
- kind: 'turn-error',
- turn: 1,
- message: 'failed',
- code: 'TRANSPORT',
- })
- const compactions = assembler([
- at(10, 'command/run', {
- commandId: 'command-1',
- name: 'compact',
- source: { kind: 'user' },
- }),
- at(11, 'compaction/start', {
- compactionId: 'manual-1',
- sourceCommandId: 'command-1',
- turn: null,
- }),
- at(12, 'compaction/summary', {
- compactionId: 'manual-1',
- sourceCommandId: 'command-1',
- summary: [{ type: 'text', text: 'manual summary' }],
- shadowedSeqs: [1, 2],
- shadowedTokenCount: 100,
- }),
- at(13, 'user/message', {
- ...textMessage('manual-checkpoint', 'checkpoint'),
- source: {
- kind: 'plugin',
- plugin: 'compact',
- compactionId: 'manual-1',
- sourceCommandId: 'command-1',
- },
- }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 2 } }),
- at(14, 'compaction/end', {
- compactionId: 'manual-1',
- sourceCommandId: 'command-1',
- turn: null,
- }),
- at(15, 'command/done', {
- commandId: 'command-1',
- kind: 'success',
- sourceEventSeq: 12,
- }),
- at(20, 'compaction/start', { compactionId: 'automatic-1', turn: null }),
- at(21, 'compaction/summary', {
- compactionId: 'automatic-1',
- summary: [{ type: 'text', text: 'automatic summary' }],
- shadowedSeqs: [3, 4],
- shadowedTokenCount: 200,
- }),
- at(22, 'user/message', {
- ...textMessage('automatic-checkpoint', 'checkpoint'),
- source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' },
- }, { surfaceOp: { op: 'replace', startSeq: 3, endSeq: 4 } }),
- at(23, 'compaction/end', { compactionId: 'automatic-1', turn: null }),
- ])
- const manual = node(snapshot(compactions), 'manual-compaction')
- expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({
- summary: 'manual summary',
- summaryEventSeq: 12,
- })
- const automatic = node(snapshot(compactions), 'compaction')
- expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 })
- expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1)
- })
- it('fills a landed compaction marker when an older page supplies its summary', () => {
- const value = assembler([
- at(13, 'user/message', {
- ...textMessage('checkpoint', 'checkpoint'),
- source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' },
- }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 8 } }),
- ], true)
- const before = node(snapshot(value), 'compaction')
- expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null })
- value.prepend([
- at(9, 'compaction/start', { compactionId: 'compact-1', turn: null }),
- at(10, 'compaction/summary', {
- compactionId: 'compact-1',
- summary: [
- { type: 'text', text: 'older ' },
- { type: 'image', data: 'ignored' },
- { type: 'text', text: 'summary' },
- ],
- shadowedSeqs: [1, 2, 3],
- shadowedTokenCount: 42,
- }),
- ], false)
- value.flush()
- const after = node(snapshot(value), 'compaction')
- expect(after?.key).toBe(before?.key)
- expect(after?.data).toMatchObject({
- summary: 'older summary',
- summaryEventSeq: 10,
- shadowedItemCount: 3,
- shadowedTokenCount: 42,
- })
- })
- it('renders a historical compaction when its start remains outside the loaded window', () => {
- const value = assembler([
- at(10, 'compaction/summary', {
- compactionId: 'compact-windowed',
- summary: [{ type: 'text', text: 'loaded summary' }],
- shadowedSeqs: [1, 2, 3],
- shadowedTokenCount: 42,
- }),
- at(11, 'user/message', {
- ...textMessage('checkpoint-windowed', 'checkpoint'),
- source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' },
- }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 3 } }),
- ], true)
- expect(node(snapshot(value), 'compaction')?.data).toMatchObject({
- summary: 'loaded summary',
- summaryEventSeq: 10,
- shadowedItemCount: 3,
- shadowedTokenCount: 42,
- })
- })
- it('ignores legacy compaction transactions without correlation ids', () => {
- const value = assembler([
- at(10, 'compaction/start', { turn: null }),
- at(11, 'compaction/end', { turn: null, error: 'This operation was aborted' }),
- at(20, 'compaction/start', { turn: null }),
- at(21, 'compaction/summary', {
- summary: [{ type: 'text', text: 'legacy summary' }],
- shadowedSeqs: [1, 2, 3],
- shadowedTokenCount: 42,
- }),
- at(22, 'user/message', {
- ...textMessage('legacy-checkpoint', 'checkpoint'),
- source: { kind: 'plugin', plugin: 'compact' },
- }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 3 } }),
- at(23, 'compaction/end', { turn: null }),
- ], true)
- expect(node(snapshot(value), 'compaction')).toBeUndefined()
- })
- it('ignores legacy retry and PTC dispatch events without correlation ids', () => {
- const value = assembler([
- at(10, 'llm/retry', {
- turn: 1,
- step: 1,
- provider: 'fake',
- mode: 'normal',
- policyKey: 'fake-normal',
- retry: 1,
- maxRetries: 2,
- delayMs: 10,
- failure: { code: 'TRANSPORT', message: 'first legacy retry' },
- }),
- at(11, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
- at(20, 'llm/retry', {
- turn: 2,
- step: 1,
- provider: 'fake',
- mode: 'normal',
- policyKey: 'fake-normal',
- retry: 1,
- maxRetries: 2,
- delayMs: 10,
- failure: { code: 'TRANSPORT', message: 'second legacy retry' },
- }),
- at(30, 'tool/ptc-dispatch-start', {
- parentCallId: 'root',
- subCallId: 'child',
- name: 'legacy-subcall',
- arguments: {},
- }),
- at(31, 'tool/ptc-dispatch', {
- parentCallId: 'root',
- subCallId: 'child',
- name: 'legacy-subcall',
- arguments: {},
- content: [],
- }),
- ], true)
- expect(node(snapshot(value), 'model-retry')).toBeUndefined()
- expect(node(snapshot(value), 'tool-call')).toBeUndefined()
- })
- it('renders the exhausted-retry turn error in a partial tail window and after prepending the chain', () => {
- const value = assembler([
- at(5, 'llm/retry', {
- retryId: 'retry-paged',
- turn: 1,
- step: 1,
- provider: 'fake',
- mode: 'normal',
- policyKey: 'fake-normal',
- retry: 2,
- maxRetries: 2,
- delayMs: 20,
- failure: { code: 'TRANSPORT', message: 'second' },
- }),
- at(6, 'step/end', { turn: 1, step: 1 }),
- at(7, 'turn/end', {
- turn: 1,
- reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
- }),
- ], true)
- expect(node(snapshot(value), 'model-retry')).toBeUndefined()
- expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({
- kind: 'turn-error',
- seq: 7,
- turn: 1,
- message: 'failed',
- code: 'TRANSPORT',
- })
- value.prepend([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'llm/retry', {
- retryId: 'retry-paged',
- turn: 1,
- step: 1,
- provider: 'fake',
- mode: 'normal',
- policyKey: 'fake-normal',
- retry: 1,
- maxRetries: 2,
- delayMs: 10,
- failure: { code: 'TRANSPORT', message: 'first' },
- }),
- at(4, 'llm/retry-started', {
- retryId: 'retry-paged', turn: 1, step: 1, retry: 1,
- }),
- ], false)
- value.flush()
- const retry = node(snapshot(value), 'model-retry')
- expect((retry?.data as RetryChatData).attempts).toHaveLength(2)
- expect(node(snapshot(value), 'turn-error')?.data).toMatchObject({
- kind: 'turn-error',
- seq: 7,
- turn: 1,
- message: 'failed',
- code: 'TRANSPORT',
- })
- })
- it('materializes a max-tokens notice and keeps completed and error turns clean', () => {
- const value = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'step/start', { turn: 1, step: 1 }),
- at(3, 'assistant/message', {
- turn: 1, step: 1, message: assistantMessage('a1', 'truncated answer'),
- }, { surfaceOp: 'append' }),
- at(4, 'step/end', { turn: 1, step: 1 }),
- at(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } }),
- ])
- const notice = node(snapshot(value), 'turn-max-tokens')
- expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 })
- expect(node(snapshot(value), 'turn-error')).toBeUndefined()
- // The tail stays the turn's last node so its branch action survives; the
- // notice slots between the truncated closing Assistant and the tail.
- const tail = node(snapshot(value), 'turn-tail')
- expect(notice?.anchorSeq).toBeLessThan(tail?.anchorSeq ?? Number.NEGATIVE_INFINITY)
- expect(notice?.anchorSeq).toBeGreaterThan(3)
- const completed = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- ])
- expect(node(snapshot(completed), 'turn-max-tokens')).toBeUndefined()
- const failed = assembler([
- at(1, 'turn/start', { turn: 1 }),
- at(2, 'turn/end', {
- turn: 1,
- reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
- }),
- ])
- expect(node(snapshot(failed), 'turn-max-tokens')).toBeUndefined()
- expect(node(snapshot(failed), 'turn-error')).toBeDefined()
- })
- it('keeps the max-tokens notice when the window starts after the owning turn/start', () => {
- const value = assembler([
- at(9, 'turn/end', { turn: 3, reason: { kind: 'max-tokens' } }),
- ], true)
- const notice = node(snapshot(value), 'turn-max-tokens')
- expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 9, turn: 3 })
- })
- it('pins the max-tokens Definition edges the engine cannot reach', () => {
- // The engine only hands start the single matched turn/end and never emits
- // update Matches for this kind; these direct calls pin the declared
- // behavior of both required Definition members anyway.
- const match = (seq: number, type: string, data: unknown) => ({
- event: { seq, time: seq * 1_000, type, data },
- role: 'start',
- location: undefined,
- }) as unknown as Parameters<typeof turnMaxTokensDefinition.start>[1]
- const context = (state: unknown, matches: unknown[] = []) => ({
- key: 'k', kind: 'turn-max-tokens', id: '1', matches, start: undefined, state, current: new Map(),
- }) as unknown as Parameters<NonNullable<typeof turnMaxTokensDefinition.buildViewNode>>[0]
- const reader = { previous: () => undefined }
- expect(() => turnMaxTokensDefinition.start(context(undefined), match(1, 'turn/start', { turn: 1 }), reader))
- .toThrow('turn-max-tokens start requires a max-tokens turn/end')
- const state = { turn: 1, seq: 5, time: 5_000 }
- expect(turnMaxTokensDefinition.update(
- context(state) as Parameters<typeof turnMaxTokensDefinition.update>[0],
- match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
- )).toBe(state)
- expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull()
- })
- it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
- const value = assembler([
- at(12, 'tool/ptc-dispatch-start', {
- rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
- }),
- at(13, 'tool/ptc-dispatch', {
- rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
- isError: false, content: [{ type: 'text', text: 'child result' }],
- }),
- at(14, 'tool/result', {
- turn: 1,
- step: 1,
- message: toolResult('root', 'root result'),
- }, { surfaceOp: 'append' }),
- at(20, 'compaction/summary', {
- compactionId: 'manual-1',
- sourceCommandId: 'command-1',
- summary: [{ type: 'text', text: 'manual summary' }],
- shadowedSeqs: [1, 2],
- shadowedTokenCount: 100,
- }),
- at(21, 'user/message', {
- ...textMessage('manual-checkpoint', 'checkpoint'),
- source: {
- kind: 'plugin',
- plugin: 'compact',
- compactionId: 'manual-1',
- sourceCommandId: 'command-1',
- },
- }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 2 } }),
- at(22, 'command/done', {
- commandId: 'command-1',
- kind: 'success',
- sourceEventSeq: 20,
- }),
- ], true)
- const tool = node(snapshot(value), 'tool-call')
- const root = (tool?.data as ToolChatData).root
- expect(root.subCalls).toHaveLength(1)
- expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' })
- const manual = node(snapshot(value), 'manual-compaction')
- expect((manual?.data as ManualCompactionChatData)).toMatchObject({
- command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } },
- compaction: { summary: 'manual summary', summaryEventSeq: 20 },
- })
- })
- })
|