Files
deepseek-harness/packages/core/agent-loop/tests/agent.spec.ts
T
2026-07-25 13:02:37 +08:00

132 lines
5.1 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function send(agent: Agent, text: string): void {
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
}
describe('Agent', () => {
it('idle inject() appends context without opening a turn or requesting a flush', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
await agent.whenIdle()
expect(flushes).toBe(0)
})
it('inject() preserves an explicitly empty plugin source', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.inject({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } })
const injected = agent.session.events.at(-1)
expect(injected?.type === 'user/message' && injected.data.source)
.toEqual({ kind: 'plugin', plugin: '' })
})
it('idle inject() rejects invalid input before append', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
expect(() => {
agent.inject({ content: [{ type: 'text', text: 'x', bad: 1n } as never], source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
})
it('steer() while idle becomes a woken prompt turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.steer({ content: [{ type: 'text', text: 'steer idle' }], source: { kind: 'plugin', plugin: 'test' } })
await agent.whenIdle()
expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)
expect(adapter.requests).toHaveLength(1)
})
it('emits one running and idle transition for one completed turn', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
send(agent, 'hi')
await agent.whenIdle()
expect(statuses).toEqual(['running', 'idle'])
})
it('whenIdle() resolves immediately without active work', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
await agent.whenIdle()
expect(agent.status).toBe('idle')
})
it('whenIdle() waits for active work until explicit cancellation', async () => {
const ctx = await harness(new MockAdapter(['hang']))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'queued')
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
agent.cancel({ kind: 'user' })
await idle
expect(agent.status).toBe('idle')
})
it('contains a throwing status listener on both transitions', async () => {
const ctx = await harness(new MockAdapter([textResponse('ok')]))
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/status', (_subject, status) => {
throw new Error(`bad ${status} listener`)
})
send(agent, 'go')
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('agent event "agent/status" listener threw'),
)
})
})