request-freeze.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. /** Request immutability through the real loop, including adopted restore graphs. */
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  5. import type { Agent } from '@deepseek-ai/dsh-agent'
  6. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  7. import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
  8. import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm'
  9. import { Session, SessionId, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
  10. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  11. import * as values from '@deepseek-ai/dsh-util-values'
  12. import { ReactLoopAgent } from '../src/agent.ts'
  13. import { MockAdapter, textResponse } from './mock-adapter.ts'
  14. const cleanups: (() => Promise<void>)[] = []
  15. afterEach(async () => {
  16. try {
  17. for (const cleanup of cleanups.reverse()) await cleanup()
  18. } finally {
  19. cleanups.length = 0
  20. vi.restoreAllMocks()
  21. }
  22. })
  23. async function harness(adapter?: MockAdapter): Promise<Context> {
  24. const ctx = new Context()
  25. cleanups.push(() => ctx.fiber.dispose())
  26. await mountAgentLoopTestDependencies(ctx)
  27. await ctx.plugin(SessionProjectionRegistry)
  28. await ctx.plugin(AgentLoop, { agents: [] })
  29. if (adapter) ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter))
  30. return ctx
  31. }
  32. async function send(agent: Agent, text: string): Promise<void> {
  33. agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
  34. await agent.whenIdle()
  35. }
  36. function expectFrozen(value: unknown): void {
  37. if (value === null || typeof value !== 'object' || value instanceof AbortSignal) return
  38. expect(Object.isFrozen(value)).toBe(true)
  39. for (const child of Object.values(value)) expectFrozen(child)
  40. }
  41. describe('loop-owned request freezing', () => {
  42. it('adopts restored identities, freezes nested messages at dispatch, and leaves event wrappers mutable', async () => {
  43. const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three'), textResponse('four')]))
  44. const id = SessionId('restored-freeze')
  45. const seed = Session.create(id)
  46. seed.append('user/message', createUserMessage({
  47. content: [{ type: 'text', text: 'restored user' }], source: { kind: 'user' },
  48. }), { surfaceOp: 'append' })
  49. seed.append('assistant/message', {
  50. turn: 1, step: 1,
  51. message: createAssistantMessage({
  52. content: [{ type: 'text', text: 'restored assistant' }],
  53. source: { provider: 'mock', model: 'mock', replayState: { nested: ['opaque'] } },
  54. }),
  55. stream: [],
  56. }, { surfaceOp: 'append' })
  57. const events = structuredClone(seed.snapshotEvents())
  58. const userEvent = events.find(event => event.type === 'user/message')!
  59. const assistantEvent = events.find(event => event.type === 'assistant/message')!
  60. Object.freeze(userEvent.data)
  61. const freeze = vi.spyOn(values, 'deepFreeze')
  62. const session = Session.fromRestore(id, events, {
  63. id, version: SESSION_FORMAT_VERSION, createdAt: 1, cwd: '/test', isSeeded: false,
  64. }, SessionLogOffset(0), 'detached')
  65. const before = session.deriveMessages()
  66. expect(before[0]).toBe(userEvent.data)
  67. expect(before[1]).toBe(assistantEvent.data.message)
  68. expect(Object.isFrozen(before)).toBe(false)
  69. expect(Object.isFrozen(userEvent.data.content)).toBe(false)
  70. expect(Object.isFrozen(assistantEvent.data.message)).toBe(false)
  71. ctx.effect(() => ctx.sessions.enter(session))
  72. const agent = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session)
  73. cleanups.push(async () => {
  74. agent.cancel({ kind: 'disposed' })
  75. await agent.whenIdle()
  76. await agent.scope.dispose()
  77. })
  78. const requests: GenerateOptions[] = []
  79. const errors: unknown[] = []
  80. ctx.on('agent/error', ({ error }) => { errors.push(error) })
  81. ctx.on('llm/stream', (request, next) => {
  82. expect(isAgentLoopRequest(request)).toBe(true)
  83. expectFrozen(request)
  84. requests.push(request)
  85. return next()
  86. })
  87. await send(agent, 'first')
  88. expect(errors).toEqual([])
  89. expect(requests).toHaveLength(1)
  90. const first = requests[0]!
  91. expect(first.messages[0]).toBe(before[0])
  92. expect(first.messages[1]).toBe(before[1])
  93. expect(Object.isFrozen(userEvent)).toBe(false)
  94. expect(Object.isFrozen(assistantEvent.data)).toBe(false)
  95. expect(Object.isFrozen(assistantEvent.data.stream)).toBe(false)
  96. userEvent.time += 1
  97. assistantEvent.data.stream.push({ type: 'chunk', time: 2, chunk: { type: 'finish', reason: { kind: 'stop' } } })
  98. before.pop()
  99. const held = JSON.stringify(first.messages)
  100. await send(agent, 'second')
  101. expect(requests).toHaveLength(2)
  102. expect(requests[1]!.messages).not.toBe(first.messages)
  103. expect(requests[1]!.messages[0]).toBe(first.messages[0])
  104. expect(requests[1]!.messages.length).toBeGreaterThan(first.messages.length)
  105. const nodes = session.surface.nodes
  106. const replacement = session.append('user/message', {
  107. ...userEvent.data, content: [{ type: 'text', text: 'compacted' }],
  108. }, {
  109. surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
  110. sourceEventSeqs: [nodes[0]!, nodes[1]!],
  111. })
  112. await send(agent, 'third')
  113. expect(requests).toHaveLength(3)
  114. expect(requests[2]!.messages[0]).toBe(replacement.data)
  115. expect(requests[2]!.messages[0]!.id).toBe(first.messages[0]!.id)
  116. expect(requests[2]!.messages[0]).not.toBe(first.messages[0])
  117. expect(JSON.stringify(first.messages)).toBe(held)
  118. expect(Object.isFrozen(session.deriveMessages())).toBe(false)
  119. expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1)
  120. expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1)
  121. const resumed = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session)
  122. cleanups.push(async () => {
  123. resumed.cancel({ kind: 'disposed' })
  124. await resumed.whenIdle()
  125. await resumed.scope.dispose()
  126. })
  127. await send(resumed, 'fresh loop')
  128. expect(requests).toHaveLength(4)
  129. expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(2)
  130. })
  131. it('retries freezing an identity whose previous traversal failed', async () => {
  132. const ctx = await harness(new MockAdapter([textResponse('done')]))
  133. const agent = await ctx.agentLoop.create(SessionId('freeze-failure'), { provider: 'mock', model: 'mock' })
  134. const message = agent.session.append('user/message', createUserMessage({
  135. content: [{ type: 'text', text: 'history' }], source: { kind: 'user' },
  136. }), { surfaceOp: 'append' }).data
  137. const realFreeze = values.deepFreeze
  138. let traversals = 0
  139. vi.spyOn(values, 'deepFreeze').mockImplementation((value) => {
  140. if (value === message && ++traversals === 1) throw new Error('freeze traversal failed')
  141. return realFreeze(value)
  142. })
  143. const errors: unknown[] = []
  144. const requests: GenerateOptions[] = []
  145. ctx.on('agent/error', ({ error }) => { errors.push(error) })
  146. ctx.on('llm/stream', (request, next) => { requests.push(request); return next() })
  147. await send(agent, 'failed turn')
  148. expect(errors).toEqual([new Error('freeze traversal failed')])
  149. expect(requests).toHaveLength(0)
  150. await send(agent, 'retry turn')
  151. expect(requests).toHaveLength(1)
  152. expect(traversals).toBe(2)
  153. expect(requests[0]!.messages[0]).toBe(message)
  154. expectFrozen(requests[0])
  155. })
  156. it.each([true, false])('freezes each local header with an adapter present: %s', async (registered) => {
  157. const adapter = registered ? new MockAdapter([textResponse('one'), textResponse('two')]) : undefined
  158. const ctx = await harness(adapter)
  159. const schemas: ToolSchema[][] = []
  160. const stops: string[][] = []
  161. ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
  162. const assembly = await next()
  163. const tools: ToolSchema[] = [{ name: 'nested', description: 'test', parameters: {
  164. type: 'object', properties: { value: { type: 'array', items: { type: 'string', enum: ['a', 'b'] } } },
  165. } }]
  166. schemas.push(tools)
  167. return { ...assembly, tools }
  168. })
  169. ctx.on('agent/request', async (_payload, next) => {
  170. const config = await next()
  171. const stop = ['stop']
  172. stops.push(stop)
  173. return { ...config, stop }
  174. })
  175. const requests: GenerateOptions[] = []
  176. const errors: unknown[] = []
  177. ctx.on('agent/error', ({ error }) => { errors.push(error) })
  178. ctx.on('llm/stream', (request, next) => {
  179. expect(isAgentLoopRequest(request)).toBe(true)
  180. expectFrozen(request)
  181. requests.push(request)
  182. return registered ? next() : (async function* () { yield* textResponse('virtual') })()
  183. })
  184. const agent = await ctx.agentLoop.create(SessionId('headers'), { provider: 'mock', model: 'mock' })
  185. await send(agent, 'first')
  186. await send(agent, 'second')
  187. expect(errors).toEqual([])
  188. expect(requests).toHaveLength(2)
  189. for (const [index, request] of requests.entries()) {
  190. expect(request.tools).toBe(schemas[index])
  191. expectFrozen(schemas[index])
  192. expect(() => request.stop!.push('mutate')).toThrow(TypeError)
  193. if (!registered) expect(request.stop).toBe(stops[index])
  194. }
  195. expect(agent.session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1)
  196. expect(agent.session.requestHeader()!.tools).not.toBe(requests[0]!.tools)
  197. expect(agent.session.requestHeader()!.config.stop).not.toBe(requests[0]!.stop)
  198. })
  199. it('keeps the live request signal mutable and observes cancellation after dispatch', async () => {
  200. const ctx = await harness(new MockAdapter(['hang']))
  201. const agent = await ctx.agentLoop.create(SessionId('cancel-freeze'), { provider: 'mock', model: 'mock' })
  202. const started = Promise.withResolvers<GenerateOptions>()
  203. ctx.on('llm/stream', (request, next) => { started.resolve(request); return next() })
  204. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  205. try {
  206. const request = await started.promise
  207. expect(Object.isFrozen(request)).toBe(true)
  208. expect(Object.isFrozen(request.signal)).toBe(false)
  209. expect(request.signal!.aborted).toBe(false)
  210. const aborted = Promise.withResolvers<undefined>()
  211. request.signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
  212. agent.cancel({ kind: 'user' })
  213. await aborted.promise
  214. await agent.whenIdle()
  215. expect(request.signal!.aborted).toBe(true)
  216. expect(request.signal!.reason).toEqual({ kind: 'user' })
  217. expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
  218. type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } },
  219. })
  220. } finally {
  221. agent.cancel({ kind: 'disposed' })
  222. await agent.whenIdle()
  223. }
  224. })
  225. })