import { createUserMessage } from '@deepseek-ai/dsh-llm' 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 { 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(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } describe('Agent', () => { it('does not echo caller-owned message identities from delivery methods', async () => { const adapter = new MockAdapter([ textResponse('one'), textResponse('two'), textResponse('three'), ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const message = (text: string) => createUserMessage({ content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, }) const call = (method: 'send' | 'inject' | 'followup' | 'steer', args: unknown[]): unknown => { const implementation: unknown = Reflect.get(agent, method) if (typeof implementation !== 'function') throw new Error(`missing Agent.${method}`) return Reflect.apply(implementation, agent, args) } expect(call('send', [message('quiet'), { target: 'next-turn', wakeup: false, }])).toBeUndefined() expect(call('inject', [message('context')])).toBeUndefined() expect(call('followup', [message('followup')])).toBeUndefined() expect(call('steer', [message('steering')])).toBeUndefined() await agent.whenIdle() expect(adapter.requests).toHaveLength(3) }) 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(createUserMessage({ 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(createUserMessage({ 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(createUserMessage({ 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(createUserMessage({ 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('awaits the turn-end checkpoint before claiming the next queued turn', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const firstFlush = Promise.withResolvers() const flushedTurns: number[] = [] ctx.on('session/flush', async (session) => { const turnEnd = session.events.findLast(event => event.type === 'turn/end') flushedTurns.push(turnEnd?.data.turn ?? 0) if (turnEnd?.data.turn === 1) await firstFlush.promise }) send(agent, 'first') send(agent, 'second') await vi.waitFor(() => { expect(flushedTurns).toEqual([1]) }) expect(adapter.requests).toHaveLength(1) firstFlush.resolve(undefined) await agent.whenIdle() expect(adapter.requests).toHaveLength(2) expect(flushedTurns).toEqual([1, 2]) }) it('keeps whenIdle pending through the final turn checkpoint', async () => { const ctx = await harness(new MockAdapter([textResponse('done')])) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const flush = Promise.withResolvers() let flushStarted = false ctx.on('session/flush', () => { flushStarted = true return flush.promise }) send(agent, 'go') await vi.waitFor(() => { expect(flushStarted).toBe(true) }) let idleSettled = false const idle = agent.whenIdle().then(() => { idleSettled = true }) await Promise.resolve() expect(idleSettled).toBe(false) flush.resolve(undefined) await idle expect(agent.status).toBe('idle') }) it('reports a rejected turn-end checkpoint and continues queued work', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const failure = new Error('disk unavailable') const errors: { turn: number; step: number; error: unknown }[] = [] let flushes = 0 ctx.on('session/flush', () => { flushes += 1 if (flushes === 1) throw failure }) ctx.on('agent/error', (subject, turn, step, error) => { if (subject === agent) errors.push({ turn, step, error }) }) send(agent, 'first') send(agent, 'second') await agent.whenIdle() expect(adapter.requests).toHaveLength(2) expect(flushes).toBe(2) expect(errors).toEqual([{ turn: 1, step: 1, error: failure }]) expect(warning).toHaveBeenCalledWith(expect.stringContaining('session/flush failed at turn 1: disk unavailable')) warning.mockRestore() }) 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'), ) }) })