| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- /** Request immutability through the real loop, including adopted restore graphs. */
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { Context } from '@deepseek-ai/cordis'
- import AgentLoop from '@deepseek-ai/dsh-agent-loop'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
- import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
- import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm'
- import { Session, SessionId, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
- import * as values from '@deepseek-ai/dsh-util-values'
- import { ReactLoopAgent } from '../src/agent.ts'
- import { MockAdapter, textResponse } from './mock-adapter.ts'
- const cleanups: (() => Promise<void>)[] = []
- afterEach(async () => {
- try {
- for (const cleanup of cleanups.reverse()) await cleanup()
- } finally {
- cleanups.length = 0
- vi.restoreAllMocks()
- }
- })
- async function harness(adapter?: MockAdapter): Promise<{ ctx: Context; loopCtx: Context }> {
- const ctx = new Context()
- cleanups.push(() => ctx.fiber.dispose())
- await mountAgentLoopTestDependencies(ctx)
- const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
- if (adapter) ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter))
- return { ctx, loopCtx: loopFiber.ctx }
- }
- async function send(agent: Agent, text: string): Promise<void> {
- agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
- await agent.whenIdle()
- }
- function expectFrozen(value: unknown): void {
- if (value === null || typeof value !== 'object' || value instanceof AbortSignal) return
- expect(Object.isFrozen(value)).toBe(true)
- for (const child of Object.values(value)) expectFrozen(child)
- }
- describe('loop-owned request freezing', () => {
- it('adopts restored identities, freezes nested messages at dispatch, and leaves event wrappers mutable', async () => {
- const { ctx, loopCtx } = await harness(new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three'), textResponse('four')]))
- const id = SessionId('restored-freeze')
- const seed = Session.create(id)
- seed.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'restored user' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- seed.append('assistant/message', {
- turn: 1, step: 1,
- message: createAssistantMessage({
- content: [{ type: 'text', text: 'restored assistant' }],
- source: { provider: 'mock', model: 'mock', replayState: { nested: ['opaque'] } },
- }),
- stream: [],
- }, { surfaceOp: 'append' })
- const events = structuredClone(seed.snapshotEvents())
- const userEvent = events.find(event => event.type === 'user/message')!
- const assistantEvent = events.find(event => event.type === 'assistant/message')!
- Object.freeze(userEvent.data)
- const freeze = vi.spyOn(values, 'deepFreeze')
- const session = Session.fromRestore(id, events, {
- id, version: SESSION_FORMAT_VERSION, createdAt: 1, cwd: '/test', isSeeded: false,
- }, SessionLogOffset(0), 'detached')
- const before = session.deriveMessages()
- expect(before[0]).toBe(userEvent.data)
- expect(before[1]).toBe(assistantEvent.data.message)
- expect(Object.isFrozen(before)).toBe(false)
- expect(Object.isFrozen(userEvent.data.content)).toBe(false)
- expect(Object.isFrozen(assistantEvent.data.message)).toBe(false)
- ctx.effect(() => ctx.sessions.enter(session))
- const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session)
- cleanups.push(async () => {
- agent.cancel({ kind: 'disposed' })
- await agent.whenIdle()
- await agent.scope.dispose()
- })
- const requests: GenerateOptions[] = []
- const errors: unknown[] = []
- ctx.on('agent/error', ({ error }) => { errors.push(error) })
- ctx.on('llm/stream', (request, next) => {
- expect(isAgentLoopRequest(request)).toBe(true)
- expectFrozen(request)
- requests.push(request)
- return next()
- })
- await send(agent, 'first')
- expect(errors).toEqual([])
- expect(requests).toHaveLength(1)
- const first = requests[0]!
- expect(first.messages[0]).toBe(before[0])
- expect(first.messages[1]).toBe(before[1])
- expect(Object.isFrozen(userEvent)).toBe(false)
- expect(Object.isFrozen(assistantEvent.data)).toBe(false)
- expect(Object.isFrozen(assistantEvent.data.stream)).toBe(false)
- userEvent.time += 1
- assistantEvent.data.stream.push({ type: 'chunk', time: 2, chunk: { type: 'finish', reason: { kind: 'stop' } } })
- before.pop()
- const held = JSON.stringify(first.messages)
- await send(agent, 'second')
- expect(requests).toHaveLength(2)
- expect(requests[1]!.messages).not.toBe(first.messages)
- expect(requests[1]!.messages[0]).toBe(first.messages[0])
- expect(requests[1]!.messages.length).toBeGreaterThan(first.messages.length)
- const nodes = session.surface.nodes
- const replacement = session.append('user/message', {
- ...userEvent.data, content: [{ type: 'text', text: 'compacted' }],
- }, {
- surfaceOp: { op: 'replace', startSeq: nodes[0]!, endSeq: nodes[1]! },
- sourceEventSeqs: [nodes[0]!, nodes[1]!],
- })
- await send(agent, 'third')
- expect(requests).toHaveLength(3)
- expect(requests[2]!.messages[0]).toBe(replacement.data)
- expect(requests[2]!.messages[0]!.id).toBe(first.messages[0]!.id)
- expect(requests[2]!.messages[0]).not.toBe(first.messages[0])
- expect(JSON.stringify(first.messages)).toBe(held)
- expect(Object.isFrozen(session.deriveMessages())).toBe(false)
- expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1)
- expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1)
- const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session)
- cleanups.push(async () => {
- resumed.cancel({ kind: 'disposed' })
- await resumed.whenIdle()
- await resumed.scope.dispose()
- })
- await send(resumed, 'fresh loop')
- expect(requests).toHaveLength(4)
- expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(2)
- })
- it('retries freezing an identity whose previous traversal failed', async () => {
- const { ctx } = await harness(new MockAdapter([textResponse('done')]))
- const agent = await ctx.agentLoop.create(SessionId('freeze-failure'), { provider: 'mock', model: 'mock' })
- const message = agent.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'history' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' }).data
- const realFreeze = values.deepFreeze
- let traversals = 0
- vi.spyOn(values, 'deepFreeze').mockImplementation((value) => {
- if (value === message && ++traversals === 1) throw new Error('freeze traversal failed')
- return realFreeze(value)
- })
- const errors: unknown[] = []
- const requests: GenerateOptions[] = []
- ctx.on('agent/error', ({ error }) => { errors.push(error) })
- ctx.on('llm/stream', (request, next) => { requests.push(request); return next() })
- await send(agent, 'failed turn')
- expect(errors).toEqual([new Error('freeze traversal failed')])
- expect(requests).toHaveLength(0)
- await send(agent, 'retry turn')
- expect(requests).toHaveLength(1)
- expect(traversals).toBe(2)
- expect(requests[0]!.messages[0]).toBe(message)
- expectFrozen(requests[0])
- })
- it.each([true, false])('freezes each local header with an adapter present: %s', async (registered) => {
- const adapter = registered ? new MockAdapter([textResponse('one'), textResponse('two')]) : undefined
- const { ctx } = await harness(adapter)
- const schemas: ToolSchema[][] = []
- const stops: string[][] = []
- ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
- const assembly = await next()
- const tools: ToolSchema[] = [{ name: 'nested', description: 'test', parameters: {
- type: 'object', properties: { value: { type: 'array', items: { type: 'string', enum: ['a', 'b'] } } },
- } }]
- schemas.push(tools)
- return { ...assembly, tools }
- })
- ctx.on('agent/request', async (_payload, next) => {
- const config = await next()
- const stop = ['stop']
- stops.push(stop)
- return { ...config, stop }
- })
- const requests: GenerateOptions[] = []
- const errors: unknown[] = []
- ctx.on('agent/error', ({ error }) => { errors.push(error) })
- ctx.on('llm/stream', (request, next) => {
- expect(isAgentLoopRequest(request)).toBe(true)
- expectFrozen(request)
- requests.push(request)
- return registered ? next() : (async function* () { yield* textResponse('virtual') })()
- })
- const agent = await ctx.agentLoop.create(SessionId('headers'), { provider: 'mock', model: 'mock' })
- await send(agent, 'first')
- await send(agent, 'second')
- expect(errors).toEqual([])
- expect(requests).toHaveLength(2)
- for (const [index, request] of requests.entries()) {
- expect(request.tools).toBe(schemas[index])
- expectFrozen(schemas[index])
- expect(() => request.stop!.push('mutate')).toThrow(TypeError)
- if (!registered) expect(request.stop).toBe(stops[index])
- }
- expect(agent.session.snapshotEvents().filter(event => event.type === 'request/header')).toHaveLength(1)
- expect(agent.session.requestHeader()!.tools).not.toBe(requests[0]!.tools)
- expect(agent.session.requestHeader()!.config.stop).not.toBe(requests[0]!.stop)
- })
- it('keeps the live request signal mutable and observes cancellation after dispatch', async () => {
- const { ctx } = await harness(new MockAdapter(['hang']))
- const agent = await ctx.agentLoop.create(SessionId('cancel-freeze'), { provider: 'mock', model: 'mock' })
- const started = Promise.withResolvers<GenerateOptions>()
- ctx.on('llm/stream', (request, next) => { started.resolve(request); return next() })
- agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
- try {
- const request = await started.promise
- expect(Object.isFrozen(request)).toBe(true)
- expect(Object.isFrozen(request.signal)).toBe(false)
- expect(request.signal!.aborted).toBe(false)
- const aborted = Promise.withResolvers<undefined>()
- request.signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
- agent.cancel({ kind: 'user' })
- await aborted.promise
- await agent.whenIdle()
- expect(request.signal!.aborted).toBe(true)
- expect(request.signal!.reason).toEqual({ kind: 'user' })
- expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
- type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: 'user' } } },
- })
- } finally {
- agent.cancel({ kind: 'disposed' })
- await agent.whenIdle()
- }
- })
- })
|