request-freeze.spec.ts 11 KB

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