request-reconstruction.spec.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. /**
  2. * Loop-level reconstructability: every request the loop sends is a pure function of the
  3. * session log — messages derive at the step/start boundary and the header is the latest
  4. * request/header snapshot. Each request extends its predecessor unless a logged compaction
  5. * replacement or header change explains the difference.
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import { Context } from 'cordis'
  9. import LlmService, { LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  10. import type {
  11. GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk,
  12. } from '@deepseek-ai/dsh-llm'
  13. import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
  14. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  15. import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  16. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  17. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  18. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  19. async function harness(adapter: MockAdapter, persona = 'stable base') {
  20. const ctx = new Context()
  21. await ctx.plugin(LlmService)
  22. await ctx.plugin(SessionStore)
  23. await ctx.plugin(SystemPrompt, { persona })
  24. await ctx.plugin(ToolRegistry)
  25. await ctx.plugin(AgentRegistry)
  26. await ctx.plugin(AgentLoop, { agents: [] })
  27. ctx.llm.registerAdapter(['mock'], adapter)
  28. return ctx
  29. }
  30. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  31. return new Promise((resolve) => {
  32. const dispose = ctx.on('agent/status', (subject, status) => {
  33. if (subject === agent && status === 'idle') {
  34. dispose()
  35. resolve()
  36. }
  37. })
  38. })
  39. }
  40. function send(agent: Agent, text: string) {
  41. agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
  42. }
  43. /** Assert `previous` is a strict value-prefix of `current`. */
  44. function expectPrefixExtension(previous: GenerateOptions, current: GenerateOptions) {
  45. expect(current.messages.length).toBeGreaterThan(previous.messages.length)
  46. expect(current.messages.slice(0, previous.messages.length)).toEqual([...previous.messages])
  47. expect(current.system).toEqual(previous.system)
  48. expect(current.tools).toEqual(previous.tools)
  49. }
  50. function registerEcho(ctx: Context) {
  51. ctx.tools.register(defineContentToolFixture({
  52. name: 'echo',
  53. description: 'echo back',
  54. parameters: { text: { type: 'string' } },
  55. async execute(args) {
  56. return [{ type: 'text', text: `echo: ${String(args.text)}` }]
  57. },
  58. }))
  59. }
  60. describe('request stability across the loop', () => {
  61. it('each step request within a turn append-extends the previous, frozen end to end', async () => {
  62. const adapter = new MockAdapter([
  63. toolCallResponse('c1', 'echo', { text: 'one' }, 'first'),
  64. toolCallResponse('c2', 'echo', { text: 'two' }, 'second'),
  65. textResponse('done'),
  66. ])
  67. const ctx = await harness(adapter)
  68. registerEcho(ctx)
  69. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  70. send(agent, 'go')
  71. await waitForIdle(ctx, agent)
  72. expect(adapter.requests).toHaveLength(3)
  73. expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
  74. expectPrefixExtension(adapter.requests[1]!, adapter.requests[2]!)
  75. for (const request of adapter.requests) {
  76. expect(Object.isFrozen(request)).toBe(true)
  77. expect(Object.isFrozen(request.messages)).toBe(true)
  78. }
  79. // One anchoring header snapshot; no further header events (nothing changed).
  80. const headerEvents = agent.session.events.filter(e => e.type === 'request/header')
  81. expect(headerEvents).toHaveLength(1)
  82. expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial')
  83. })
  84. it('a later turn append-extends the previous turn (one conversation, one log)', async () => {
  85. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  86. const ctx = await harness(adapter)
  87. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  88. send(agent, 'first')
  89. await waitForIdle(ctx, agent)
  90. send(agent, 'second')
  91. await waitForIdle(ctx, agent)
  92. expect(adapter.requests).toHaveLength(2)
  93. expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
  94. })
  95. it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => {
  96. const reasoning = {
  97. efforts: [
  98. { id: ReasoningEffortId('high'), name: 'High' },
  99. { id: ReasoningEffortId('max'), name: 'Max' },
  100. ],
  101. defaultEffort: ReasoningEffortId('high'),
  102. }
  103. const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning)
  104. const ctx = await harness(adapter)
  105. const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' })
  106. ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => {
  107. const config = await next()
  108. return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config
  109. })
  110. send(agent, 'first')
  111. await waitForIdle(ctx, agent)
  112. send(agent, 'second')
  113. await waitForIdle(ctx, agent)
  114. expect(adapter.requests.map(request => request.reasoningEffort)).toEqual([
  115. ReasoningEffortId('high'),
  116. ReasoningEffortId('max'),
  117. ])
  118. const headers = agent.session.events.filter(event => event.type === 'request/header')
  119. expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([
  120. ReasoningEffortId('high'),
  121. ReasoningEffortId('max'),
  122. ])
  123. expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change'])
  124. for (const [model, effort] of [
  125. ['mock', ReasoningEffortId('max')],
  126. ['replacement', ReasoningEffortId('high')],
  127. ] as const) {
  128. const resumedAdapter = new MockAdapter([textResponse('resumed')], reasoning)
  129. const resumedCtx = await harness(resumedAdapter)
  130. const resumedHandle = await resumedCtx.agents.create({
  131. sessionId: SessionId(`effort-${model}`),
  132. seed: structuredClone(agent.session.events),
  133. agentOptions: { provider: 'mock', model },
  134. })
  135. send(resumedHandle.agent, 'resumed')
  136. await waitForIdle(resumedCtx, resumedHandle.agent)
  137. expect(resumedAdapter.requests[0]?.model).toBe(model)
  138. expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(effort)
  139. const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header')
  140. expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(effort)
  141. expect(resumedHeaders.at(-1)?.data.reason).toBe('resume')
  142. }
  143. })
  144. it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => {
  145. const ctx = new Context()
  146. await ctx.plugin(LlmService)
  147. await ctx.plugin(SessionStore)
  148. await ctx.plugin(SystemPrompt, { persona: 'stable base' })
  149. await ctx.plugin(ToolRegistry)
  150. await ctx.plugin(AgentRegistry)
  151. await ctx.plugin(AgentLoop, { agents: [] })
  152. const started = Promise.withResolvers<undefined>()
  153. const reasoning = Promise.withResolvers<LlmModelReasoningInfo>()
  154. const first = new class extends MockAdapter {
  155. override async resolveModel(
  156. provider: string,
  157. model: string,
  158. _signal?: AbortSignal,
  159. ): Promise<LlmResolvedModelInfo> {
  160. started.resolve(undefined)
  161. return {
  162. provider,
  163. id: model,
  164. name: model,
  165. context: { contextWindow: 64_000 },
  166. reasoning: await reasoning.promise,
  167. }
  168. }
  169. }([textResponse('first')])
  170. const second = new MockAdapter([textResponse('second')], {
  171. efforts: [{ id: ReasoningEffortId('max'), name: 'Max' }],
  172. defaultEffort: ReasoningEffortId('max'),
  173. })
  174. const disposeFirst = ctx.llm.registerAdapter(['mock'], first)
  175. const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' })
  176. const dispatched: number[] = []
  177. ctx.on('agent/model-request', (subject, _turn, _step, request) => {
  178. if (subject === agent && request.contextWindow !== undefined) {
  179. dispatched.push(request.contextWindow)
  180. }
  181. })
  182. send(agent, 'go')
  183. await started.promise
  184. disposeFirst()
  185. ctx.llm.registerAdapter(['mock'], second)
  186. reasoning.resolve({
  187. efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
  188. defaultEffort: ReasoningEffortId('high'),
  189. })
  190. await waitForIdle(ctx, agent)
  191. expect(first.requests.map(request => request.reasoningEffort)).toEqual([
  192. ReasoningEffortId('high'),
  193. ])
  194. expect(second.requests).toHaveLength(0)
  195. expect(dispatched).toEqual([64_000])
  196. const headers = agent.session.events.filter(event => event.type === 'request/header')
  197. expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high'))
  198. })
  199. it('aborts a blocked reasoning lookup before quiescent disposal completes', async () => {
  200. const started = Promise.withResolvers<AbortSignal>()
  201. const adapter = new class extends MockAdapter {
  202. override resolveModel(
  203. _provider: string,
  204. _model: string,
  205. signal?: AbortSignal,
  206. ): Promise<never> {
  207. if (signal === undefined) return Promise.reject(new Error('missing reasoning signal'))
  208. started.resolve(signal)
  209. return new Promise((_resolve, reject) => {
  210. if (signal.aborted) {
  211. reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
  212. return
  213. }
  214. signal.addEventListener('abort', () => {
  215. reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
  216. }, { once: true })
  217. })
  218. }
  219. }([])
  220. const ctx = await harness(adapter)
  221. const handle = await ctx.agents.create({
  222. sessionId: SessionId('reasoning-dispose'),
  223. agentOptions: { provider: 'mock', model: 'mock' },
  224. })
  225. send(handle.agent, 'go')
  226. const signal = await started.promise
  227. await handle.dispose()
  228. expect(signal.aborted).toBe(true)
  229. expect(handle.agent.status).toBe('idle')
  230. expect(adapter.requests).toHaveLength(0)
  231. expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false)
  232. })
  233. it.each(['plain error', 'LLM error'] as const)(
  234. 'does not swallow a %s from exact-model resolution',
  235. async (kind) => {
  236. const failure = kind === 'plain error'
  237. ? new Error('reasoning metadata failed')
  238. : new LlmError('unsupported effort', 'UNSUPPORTED_REASONING_EFFORT')
  239. const adapter = new class extends MockAdapter {
  240. override resolveModel(): Promise<never> {
  241. return Promise.reject(failure)
  242. }
  243. }([])
  244. const ctx = await harness(adapter)
  245. const errors: Error[] = []
  246. ctx.on('agent/error', (_agent, _turn, _step, error) => {
  247. if (error instanceof Error) errors.push(error)
  248. })
  249. const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
  250. provider: 'mock',
  251. model: 'mock',
  252. })
  253. send(agent, 'go')
  254. await waitForIdle(ctx, agent)
  255. expect(errors).toContain(failure)
  256. expect(adapter.requests).toHaveLength(0)
  257. },
  258. )
  259. it('lets a short-circuiting llm/stream listener own an unregistered route', async () => {
  260. const ctx = new Context()
  261. await ctx.plugin(LlmService)
  262. await ctx.plugin(SessionStore)
  263. await ctx.plugin(SystemPrompt, { persona: 'stable base' })
  264. await ctx.plugin(ToolRegistry)
  265. await ctx.plugin(AgentRegistry)
  266. await ctx.plugin(AgentLoop, { agents: [] })
  267. let observed: GenerateOptions | undefined
  268. let observedRequest: { provider: string; model: string; contextWindow?: number } | undefined
  269. ctx.on('llm/stream', (options) => {
  270. observed = options
  271. return (async function* () {
  272. yield* textResponse('owned')
  273. })()
  274. })
  275. const agent = ctx.agentLoop.create(SessionId('listener-owned'), {
  276. provider: 'listener',
  277. model: 'virtual',
  278. })
  279. ctx.on('agent/model-request', (subject, _turn, _step, request) => {
  280. if (subject === agent) observedRequest = { ...request }
  281. })
  282. send(agent, 'go')
  283. await waitForIdle(ctx, agent)
  284. expect(observed).toMatchObject({ provider: 'listener', model: 'virtual' })
  285. expect(observedRequest).toEqual({ provider: 'listener', model: 'virtual' })
  286. expect(agent.session.requestHeader()?.config).toEqual({
  287. provider: 'listener',
  288. model: 'virtual',
  289. })
  290. expect(agent.session.deriveMessages().at(-1)?.content).toContainEqual({
  291. type: 'text',
  292. text: 'owned',
  293. })
  294. })
  295. it('notifies one contained request attempt after the outer stream handle returns', async () => {
  296. const ctx = new Context()
  297. await ctx.plugin(LlmService)
  298. await ctx.plugin(SessionStore)
  299. await ctx.plugin(SystemPrompt, { persona: 'stable base' })
  300. await ctx.plugin(ToolRegistry)
  301. await ctx.plugin(AgentRegistry)
  302. await ctx.plugin(AgentLoop, { agents: [] })
  303. let resolutions = 0
  304. const adapter = new class extends LlmAdapter {
  305. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  306. resolutions += 1
  307. return Promise.resolve({
  308. provider,
  309. id: model,
  310. name: model,
  311. ...model === 'capacity'
  312. ? { context: { contextWindow: 128_000 } }
  313. : {},
  314. })
  315. }
  316. override stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  317. if (options.model === 'lazy-sync-failure') {
  318. throw new LlmError('lazy construction failed', 'CONSTRUCTION')
  319. }
  320. if (options.model === 'async-failure') {
  321. return {
  322. [Symbol.asyncIterator]: () => ({
  323. next: () => Promise.reject(new LlmError('iteration failed', 'ITERATION')),
  324. }),
  325. }
  326. }
  327. return (async function* () {
  328. yield* textResponse(options.model)
  329. })()
  330. }
  331. }()
  332. ctx.llm.registerAdapter(['mock'], adapter)
  333. const agent = ctx.agentLoop.create(SessionId('model-request-live'), {
  334. provider: 'mock',
  335. model: 'capacity',
  336. })
  337. const returnedHandles = new Set<string>()
  338. ctx.on('llm/stream', (options, next) => {
  339. if (options.model === 'outer-failure') {
  340. throw new Error('outer waterfall failed before returning a handle')
  341. }
  342. if (options.model === 'lazy-sync-failure') {
  343. const stream = (async function* () {
  344. yield* next()
  345. })()
  346. returnedHandles.add(options.model)
  347. return stream
  348. }
  349. const stream = next()
  350. returnedHandles.add(options.model)
  351. return stream
  352. })
  353. const observed: {
  354. turn: number
  355. step: number
  356. provider: string
  357. model: string
  358. contextWindow?: number
  359. }[] = []
  360. const observedBeforeHandleReturn: string[] = []
  361. ctx.on('agent/model-request', (subject) => {
  362. if (subject === agent) throw new Error('observer failed')
  363. })
  364. ctx.on('agent/model-request', (subject, turn, step, request) => {
  365. if (subject !== agent) return
  366. if (!returnedHandles.has(request.model)) observedBeforeHandleReturn.push(request.model)
  367. observed.push({ turn, step, ...request })
  368. })
  369. ctx.on('agent/request', async (_subject, turn, _step, _signal, next) => ({
  370. ...await next(),
  371. model: [
  372. 'capacity',
  373. 'unknown',
  374. 'async-failure',
  375. 'lazy-sync-failure',
  376. 'outer-failure',
  377. ][turn - 1]!,
  378. }))
  379. for (const prompt of ['one', 'two', 'three', 'four', 'five']) {
  380. send(agent, prompt)
  381. await waitForIdle(ctx, agent)
  382. }
  383. expect(observed).toEqual([
  384. {
  385. turn: 1,
  386. step: 1,
  387. provider: 'mock',
  388. model: 'capacity',
  389. contextWindow: 128_000,
  390. },
  391. {
  392. turn: 2,
  393. step: 1,
  394. provider: 'mock',
  395. model: 'unknown',
  396. },
  397. {
  398. turn: 3,
  399. step: 1,
  400. provider: 'mock',
  401. model: 'async-failure',
  402. },
  403. {
  404. turn: 4,
  405. step: 1,
  406. provider: 'mock',
  407. model: 'lazy-sync-failure',
  408. },
  409. ])
  410. expect(resolutions).toBe(5)
  411. expect(observedBeforeHandleReturn).toEqual([])
  412. expect(returnedHandles.has('outer-failure')).toBe(false)
  413. })
  414. it('a compaction replace rewrites the resend, and the log explains it', async () => {
  415. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  416. const ctx = await harness(adapter)
  417. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  418. send(agent, 'first')
  419. await waitForIdle(ctx, agent)
  420. // A pre-step listener compacts turn 1's history before turn 2's step —
  421. // the sanctioned surface rewrite, landing OUTSIDE the step.
  422. const preStep = ctx.on('agent/step', () => {
  423. preStep()
  424. const session = agent.session
  425. const nodes = session.surface.nodes
  426. session.append('user/message', {
  427. content: [{ type: 'text', text: '[summary of turn 1]' }],
  428. source: { kind: 'plugin', plugin: 'test-compact' },
  429. }, {
  430. surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
  431. sourceEventSeqs: [nodes[0]!, nodes[1]!],
  432. })
  433. })
  434. send(agent, 'second')
  435. await waitForIdle(ctx, agent)
  436. const second = adapter.requests[1]!
  437. // The rewritten history: summary replaces turn 1's user+assistant pair.
  438. expect(second.messages[0]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true)
  439. // No header event beyond the anchor: the replace is itself in the log.
  440. expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
  441. })
  442. it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => {
  443. const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')])
  444. const ctx = await harness(adapter)
  445. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  446. send(agent, 'first')
  447. await waitForIdle(ctx, agent)
  448. send(agent, 'second')
  449. await waitForIdle(ctx, agent)
  450. // Identical assembly re-rendered per step is NOT a change.
  451. expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
  452. ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' })
  453. send(agent, 'third')
  454. await waitForIdle(ctx, agent)
  455. const snapshots = agent.session.events.filter(e => e.type === 'request/header')
  456. expect(snapshots).toHaveLength(2)
  457. expect(snapshots[1]?.data.reason).toBe('change')
  458. expect(adapter.requests[2]!.system).toContain('new guidance')
  459. // History is preserved across the change — only the header moved.
  460. expect(adapter.requests[2]!.messages.length).toBeGreaterThan(adapter.requests[1]!.messages.length)
  461. })
  462. it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => {
  463. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  464. const ctx = await harness(adapter)
  465. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  466. let injected = false
  467. ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
  468. if (!injected) {
  469. injected = true
  470. agent.inject({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })
  471. }
  472. return next()
  473. })
  474. send(agent, 'first')
  475. await waitForIdle(ctx, agent)
  476. const first = adapter.requests[0]!
  477. // The inject landed in the log after the boundary: not in THIS request…
  478. expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false)
  479. expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true)
  480. send(agent, 'second')
  481. await waitForIdle(ctx, agent)
  482. // …but in the next one, at its logged position.
  483. const second = adapter.requests[1]!
  484. expect(second.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(true)
  485. })
  486. it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => {
  487. const adapter = new MockAdapter([textResponse('one')])
  488. const ctx = await harness(adapter)
  489. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  490. const errors: Error[] = []
  491. ctx.on('agent/error', (_agent, _turn, _step, error) => {
  492. if (error instanceof Error) errors.push(error)
  493. })
  494. ctx.on('llm/stream', (options, next) => {
  495. // The historical failure mode this design kills: a listener rewriting
  496. // request content in place. The freeze turns it into a loud error.
  497. options.messages.push({ role: 'user', content: [{ type: 'text', text: 'sneaky' }] })
  498. return next()
  499. })
  500. send(agent, 'go')
  501. await waitForIdle(ctx, agent)
  502. expect(errors).toHaveLength(1)
  503. expect(errors[0]!.message).toMatch(/not extensible|frozen|read only|readonly/i)
  504. })
  505. it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => {
  506. const adapter = new MockAdapter([textResponse('one')])
  507. const ctx = await harness(adapter)
  508. const agent = ctx.agentLoop.create(SessionId('gen1'), { provider: 'mock', model: 'mock' })
  509. send(agent, 'first')
  510. await waitForIdle(ctx, agent)
  511. // Second generation: a new agent whose session is seeded with the first
  512. // one's full log (the resume/fork path).
  513. const adapter2 = new MockAdapter([textResponse('two')])
  514. const ctx2 = await harness(adapter2)
  515. const handle = await ctx2.agents.create({
  516. sessionId: SessionId('gen2-session'),
  517. seed: [...agent.session.events],
  518. agentOptions: { provider: 'mock', model: 'mock' },
  519. })
  520. const agent2 = handle.agent
  521. send(agent2, 'second')
  522. await waitForIdle(ctx2, agent2)
  523. const snapshots = agent2.session.events.filter(e => e.type === 'request/header')
  524. expect(snapshots).toHaveLength(2)
  525. expect(snapshots[1]?.data.reason).toBe('resume')
  526. // Identical header across the restart: byte-identical continuation.
  527. expect(adapter2.requests[0]!.system).toEqual(adapter.requests[0]!.system)
  528. expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!)
  529. })
  530. it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => {
  531. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  532. const ctx = await harness(adapter)
  533. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  534. ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
  535. const config = await next()
  536. // next() resolves the SAME frozen seed — in-place shaping after
  537. // delegation is unrepresentable, so a "mutate what next() returned"
  538. // listener cannot desync the log from the request (nor reach the
  539. // session's cached header fold, which is deep-cloned away and itself
  540. // frozen).
  541. expect(Object.isFrozen(config)).toBe(true)
  542. expect(() => { (config as { temperature?: number }).temperature = 0.9 }).toThrow(TypeError)
  543. return config
  544. })
  545. send(agent, 'first')
  546. await waitForIdle(ctx, agent)
  547. send(agent, 'second')
  548. await waitForIdle(ctx, agent)
  549. // No changed snapshot was logged (nothing really changed), and the session's own
  550. // fold is immutable state.
  551. expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
  552. expect(Object.isFrozen(agent.session.requestHeader())).toBe(true)
  553. expect(adapter.requests[1]!.temperature).toBeUndefined()
  554. })
  555. it('THEOREM: every request rebuilds byte-equal from the session log alone', async () => {
  556. const adapter = new MockAdapter([
  557. toolCallResponse('c1', 'echo', { text: 'one' }, 'calling'),
  558. textResponse('done'),
  559. textResponse('after change'),
  560. ])
  561. const ctx = await harness(adapter)
  562. registerEcho(ctx)
  563. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  564. send(agent, 'go')
  565. await waitForIdle(ctx, agent)
  566. ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' })
  567. ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
  568. ...await next(), temperature: 0.5, maxTokens: 99, stop: ['<END>'],
  569. }))
  570. send(agent, 'again')
  571. await waitForIdle(ctx, agent)
  572. expect(adapter.requests).toHaveLength(3)
  573. const events = agent.session.events
  574. const stepStarts = events.filter(e => e.type === 'step/start')
  575. expect(stepStarts).toHaveLength(3)
  576. adapter.requests.forEach((request, index) => {
  577. const stepStart = stepStarts[index]!
  578. // Messages: the derivation over the log prefix strictly before this
  579. // step's step/start — rebuilt here through a completely fresh Session.
  580. const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
  581. expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
  582. // Header: the latest request/header snapshot up to this step's dispatch
  583. // (its header event sits between step/start and the first chunk).
  584. const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
  585. const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
  586. expect(request.model).toBe(header.config.model)
  587. expect(request.reasoningEffort).toBe(header.config.reasoningEffort)
  588. expect(request.system).toEqual(header.system)
  589. expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? []))
  590. expect(request.temperature).toBe(header.config.temperature)
  591. expect(request.maxTokens).toBe(header.config.maxTokens)
  592. expect(request.stop).toEqual(header.config.stop)
  593. })
  594. })
  595. })