request-reconstruction.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. /**
  2. * Loop-level reconstructability: every request the loop sends is a pure function of the
  3. * session log — messages are the derivation at the step/start boundary, the header is the fold
  4. * of request/header* events — and every request is an append-extension of its predecessor
  5. * unless a logged event (compaction replace, header change) explains the difference. Mock-adapter
  6. * requests are the observable, and the final offline rebuild states the full contract end to end.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import { Context } from 'cordis'
  10. import LlmService from '@deepseek-ai/dsh-llm'
  11. import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
  12. import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
  13. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  14. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  15. import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
  16. import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  17. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  18. async function harness(adapter: MockAdapter, persona = 'stable base') {
  19. const ctx = new Context()
  20. await ctx.plugin(LlmService)
  21. await ctx.plugin(SessionStore)
  22. await ctx.plugin(SystemPrompt, { persona })
  23. await ctx.plugin(ToolRegistry)
  24. await ctx.plugin(AgentRegistry)
  25. await ctx.plugin(AgentLoop, { agents: [] })
  26. ctx.llm.registerAdapter(['mock'], adapter)
  27. return ctx
  28. }
  29. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  30. return new Promise((resolve) => {
  31. const dispose = ctx.on('agent/status', (subject, status) => {
  32. if (subject === agent && status === 'idle') {
  33. dispose()
  34. resolve()
  35. }
  36. })
  37. })
  38. }
  39. function send(agent: ReactLoopAgent, text: string) {
  40. agent.send([{ type: 'text', text }])
  41. }
  42. /** Assert `previous` is a strict value-prefix of `current`. */
  43. function expectPrefixExtension(previous: GenerateOptions, current: GenerateOptions) {
  44. expect(current.messages.length).toBeGreaterThan(previous.messages.length)
  45. expect(current.messages.slice(0, previous.messages.length)).toEqual([...previous.messages])
  46. expect(current.system).toEqual(previous.system)
  47. expect(current.tools).toEqual(previous.tools)
  48. }
  49. function registerEcho(ctx: Context) {
  50. ctx.tools.register(defineTool({
  51. name: 'echo',
  52. description: 'echo back',
  53. parameters: { text: { type: 'string' } },
  54. async execute(args) {
  55. return [{ type: 'text', text: `echo: ${String(args.text)}` }]
  56. },
  57. }))
  58. }
  59. describe('request stability across the loop', () => {
  60. it('each step request within a turn append-extends the previous, frozen end to end', async () => {
  61. const adapter = new MockAdapter([
  62. toolCallResponse('c1', 'echo', { text: 'one' }, 'first'),
  63. toolCallResponse('c2', 'echo', { text: 'two' }, 'second'),
  64. textResponse('done'),
  65. ])
  66. const ctx = await harness(adapter)
  67. registerEcho(ctx)
  68. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  69. send(agent, 'go')
  70. await waitForIdle(ctx, agent)
  71. expect(adapter.requests).toHaveLength(3)
  72. expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
  73. expectPrefixExtension(adapter.requests[1]!, adapter.requests[2]!)
  74. for (const request of adapter.requests) {
  75. expect(Object.isFrozen(request)).toBe(true)
  76. expect(Object.isFrozen(request.messages)).toBe(true)
  77. }
  78. // One anchoring header snapshot; no further header events (nothing changed).
  79. const headerEvents = agent.session.events.filter(e => e.type === 'request/header' || e.type === 'request/header-delta')
  80. expect(headerEvents).toHaveLength(1)
  81. expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial')
  82. })
  83. it('a later turn append-extends the previous turn (one conversation, one log)', async () => {
  84. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  85. const ctx = await harness(adapter)
  86. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  87. send(agent, 'first')
  88. await waitForIdle(ctx, agent)
  89. send(agent, 'second')
  90. await waitForIdle(ctx, agent)
  91. expect(adapter.requests).toHaveLength(2)
  92. expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
  93. })
  94. it('a compaction replace rewrites the resend, and the log explains it', async () => {
  95. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  96. const ctx = await harness(adapter)
  97. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  98. send(agent, 'first')
  99. await waitForIdle(ctx, agent)
  100. // A pre-step listener compacts turn 1's history before turn 2's step —
  101. // the sanctioned surface rewrite, landing OUTSIDE the step.
  102. const preStep = ctx.on('agent/pre-step', () => {
  103. preStep()
  104. const session = agent.session
  105. const nodes = session.surface.nodes
  106. session.append('context/message', {
  107. content: [{ type: 'text', text: '[summary of turn 1]' }],
  108. source: { kind: 'plugin', plugin: 'test-compact' },
  109. }, {
  110. surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq },
  111. sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq],
  112. })
  113. })
  114. send(agent, 'second')
  115. await waitForIdle(ctx, agent)
  116. const second = adapter.requests[1]!
  117. // The rewritten history: summary replaces turn 1's user+assistant pair.
  118. expect(second.messages[0]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true)
  119. // No header event beyond the anchor: the replace is itself in the log.
  120. expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
  121. })
  122. it('a real system-prompt change is a logged header delta; a stable prompt logs nothing', async () => {
  123. const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')])
  124. const ctx = await harness(adapter)
  125. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  126. send(agent, 'first')
  127. await waitForIdle(ctx, agent)
  128. send(agent, 'second')
  129. await waitForIdle(ctx, agent)
  130. // Identical assembly re-rendered per step is NOT a change.
  131. expect(agent.session.events.filter(e => e.type === 'request/header-delta')).toHaveLength(0)
  132. ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' })
  133. send(agent, 'third')
  134. await waitForIdle(ctx, agent)
  135. const deltas = agent.session.events.filter(e => e.type === 'request/header-delta')
  136. expect(deltas).toHaveLength(1)
  137. expect(adapter.requests[2]!.system).toContain('new guidance')
  138. // History is preserved across the change — only the header moved.
  139. expect(adapter.requests[2]!.messages.length).toBeGreaterThan(adapter.requests[1]!.messages.length)
  140. })
  141. it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => {
  142. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  143. const ctx = await harness(adapter)
  144. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  145. let injected = false
  146. ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
  147. if (!injected) {
  148. injected = true
  149. agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } })
  150. }
  151. return next()
  152. })
  153. send(agent, 'first')
  154. await waitForIdle(ctx, agent)
  155. const first = adapter.requests[0]!
  156. // The inject landed in the log after the boundary: not in THIS request…
  157. expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false)
  158. expect(agent.session.events.some(e => e.type === 'context/message')).toBe(true)
  159. send(agent, 'second')
  160. await waitForIdle(ctx, agent)
  161. // …but in the next one, at its logged position.
  162. const second = adapter.requests[1]!
  163. expect(second.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(true)
  164. })
  165. it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => {
  166. const adapter = new MockAdapter([textResponse('one')])
  167. const ctx = await harness(adapter)
  168. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  169. const errors: Error[] = []
  170. ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
  171. ctx.on('llm/stream', (options, next) => {
  172. // The historical failure mode this design kills: a listener rewriting
  173. // request content in place. The freeze turns it into a loud error.
  174. options.messages.push({ role: 'user', content: [{ type: 'text', text: 'sneaky' }] })
  175. return next()
  176. })
  177. send(agent, 'go')
  178. await waitForIdle(ctx, agent)
  179. expect(errors).toHaveLength(1)
  180. expect(errors[0]!.message).toMatch(/not extensible|frozen|read only|readonly/i)
  181. })
  182. it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => {
  183. const adapter = new MockAdapter([textResponse('one')])
  184. const ctx = await harness(adapter)
  185. const agent = ctx.agentLoop.create(AgentId('gen1'), { model: 'mock' })
  186. send(agent, 'first')
  187. await waitForIdle(ctx, agent)
  188. // Second generation: a new agent whose session is seeded with the first
  189. // one's full log (the resume/fork path).
  190. const adapter2 = new MockAdapter([textResponse('two')])
  191. const ctx2 = await harness(adapter2)
  192. const handle = await ctx2.agents.create({
  193. agentId: AgentId('gen2'),
  194. sessionId: SessionId('gen2-session'),
  195. seed: [...agent.session.events],
  196. agentOptions: { model: 'mock' },
  197. })
  198. const agent2 = handle.agent as ReactLoopAgent
  199. send(agent2, 'second')
  200. await waitForIdle(ctx2, agent2)
  201. const snapshots = agent2.session.events.filter(e => e.type === 'request/header')
  202. expect(snapshots).toHaveLength(2)
  203. expect(snapshots[1]?.type === 'request/header' && snapshots[1].data.reason).toBe('resume')
  204. // Identical header across the restart: byte-identical continuation.
  205. expect(adapter2.requests[0]!.system).toEqual(adapter.requests[0]!.system)
  206. expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!)
  207. })
  208. it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => {
  209. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  210. const ctx = await harness(adapter)
  211. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  212. ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
  213. const config = await next()
  214. // next() resolves the SAME frozen seed — in-place shaping after
  215. // delegation is unrepresentable, so a "mutate what next() returned"
  216. // listener cannot desync the log from the request (nor reach the
  217. // session's cached header fold, which is deep-cloned away and itself
  218. // frozen).
  219. expect(Object.isFrozen(config)).toBe(true)
  220. expect(() => { (config as { temperature?: number }).temperature = 0.9 }).toThrow(TypeError)
  221. return config
  222. })
  223. send(agent, 'first')
  224. await waitForIdle(ctx, agent)
  225. send(agent, 'second')
  226. await waitForIdle(ctx, agent)
  227. // No delta was logged (nothing really changed), and the session's own
  228. // fold is immutable state.
  229. expect(agent.session.events.filter(e => e.type === 'request/header-delta')).toHaveLength(0)
  230. expect(Object.isFrozen(agent.session.requestHeader())).toBe(true)
  231. expect(adapter.requests[1]!.temperature).toBeUndefined()
  232. })
  233. it('THEOREM: every request rebuilds byte-equal from the session log alone', async () => {
  234. const adapter = new MockAdapter([
  235. toolCallResponse('c1', 'echo', { text: 'one' }, 'calling'),
  236. textResponse('done'),
  237. textResponse('after change'),
  238. ])
  239. const ctx = await harness(adapter)
  240. registerEcho(ctx)
  241. const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
  242. send(agent, 'go')
  243. await waitForIdle(ctx, agent)
  244. ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' })
  245. ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: ['<END>'] }))
  246. send(agent, 'again')
  247. await waitForIdle(ctx, agent)
  248. expect(adapter.requests).toHaveLength(3)
  249. const events = agent.session.events
  250. const stepStarts = events.filter(e => e.type === 'step/start')
  251. expect(stepStarts).toHaveLength(3)
  252. adapter.requests.forEach((request, index) => {
  253. const stepStart = stepStarts[index]!
  254. // Messages: the derivation over the log prefix strictly before this
  255. // step's step/start — rebuilt here through a completely fresh Session.
  256. const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
  257. expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
  258. // Header: the fold of request/header* events up to this step's dispatch
  259. // (its header event sits between step/start and the first chunk).
  260. const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
  261. const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
  262. expect(request.model).toBe(header.config.model)
  263. expect(request.system).toEqual(header.system)
  264. expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? []))
  265. expect(request.temperature).toBe(header.config.temperature)
  266. expect(request.maxTokens).toBe(header.config.maxTokens)
  267. expect(request.stop).toEqual(header.config.stop)
  268. })
  269. })
  270. })