import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { type Agent, type InboxItem } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { createUserMessage } 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 prompt(agent: Agent, text: string): void { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' }, })) } function itemText(item: InboxItem): string { return item.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') } interface InboxRecording { readonly events: string[] readonly enqueued: InboxItem['id'][] readonly dequeued: InboxItem['id'][] readonly discarded: InboxItem['id'][] } /** Record the complete inbox lifecycle of one agent for order and identity assertions. */ function recordInbox(ctx: Context): InboxRecording { const events: string[] = [] const enqueued: InboxItem['id'][] = [] const dequeued: InboxItem['id'][] = [] const discarded: InboxItem['id'][] = [] ctx.on('agent/inbox/enqueue', (_agent, item) => { events.push(`enqueue:${item.placement}:${itemText(item)}`) enqueued.push(item.id) }) ctx.on('agent/inbox/dequeue', (_agent, item) => { events.push(`dequeue:${itemText(item)}`) dequeued.push(item.id) }) ctx.on('agent/inbox/discard', (_agent, items) => { events.push(`discard:${items.map(itemText).join(',')}`) discarded.push(...items.map(item => item.id)) }) return { events, enqueued, dequeued, discarded } } /** Text of every ordinary prompt the log admitted, in durable order. */ function promptTexts(agent: Agent): string[] { return agent.session.events.flatMap(event => event.type === 'user/message' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) } describe('idle turn admission reservation', () => { it('holds later waking prompts in the FIFO until release', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const inbox = recordInbox(ctx) const release = agent.reserveTurnAdmission() expect(release).toBeDefined() prompt(agent, 'first prompt') prompt(agent, 'second prompt') expect(agent.acceptsNextStep).toBe(false) await new Promise((resolve) => { setTimeout(resolve, 5) }) expect(agent.status).toBe('idle') expect(adapter.requests).toHaveLength(0) expect(agent.session.events).toHaveLength(0) expect(inbox.events).toEqual([ 'enqueue:queued:first prompt', 'enqueue:queued:second prompt', ]) release?.() await agent.whenIdle() expect(promptTexts(agent)).toEqual(['first prompt', 'second prompt']) expect(agent.session.events.flatMap(event => event.type === 'turn/start' ? [event.data.turn] : [])).toEqual([1, 2]) expect(inbox.events).toEqual([ 'enqueue:queued:first prompt', 'enqueue:queued:second prompt', 'dequeue:first prompt', 'dequeue:second prompt', ]) expect(inbox.dequeued).toEqual(inbox.enqueued) expect(inbox.discarded).toEqual([]) }) it('refuses acquisition when an accepted waking prompt still owns the next turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) prompt(agent, 'accepted first') expect(agent.status).toBe('idle') expect(agent.reserveTurnAdmission()).toBeUndefined() await agent.whenIdle() expect(adapter.requests).toHaveLength(1) }) it('refuses acquisition while a turn is running', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reserved: unknown[] = [] ctx.on('agent/step', () => { reserved.push(agent.reserveTurnAdmission()) }) prompt(agent, 'running') await agent.whenIdle() expect(agent.status).toBe('idle') expect(reserved).toEqual([undefined]) }) it('refuses a second reservation and releases idempotently', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const release = agent.reserveTurnAdmission() expect(agent.reserveTurnAdmission()).toBeUndefined() prompt(agent, 'queued behind the reservation') release?.() release?.() await agent.whenIdle() expect(promptTexts(agent)).toEqual(['queued behind the reservation']) expect(adapter.requests).toHaveLength(1) const second = agent.reserveTurnAdmission() expect(second).toBeDefined() second?.() }) it('ignores a stale release once a later reservation owns the boundary', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const stale = agent.reserveTurnAdmission() stale?.() const live = agent.reserveTurnAdmission() prompt(agent, 'held by the live reservation') stale?.() await new Promise((resolve) => { setTimeout(resolve, 5) }) expect(adapter.requests).toHaveLength(0) live?.() await agent.whenIdle() expect(adapter.requests).toHaveLength(1) }) it('acquires beside quiet queued work and leaves it queued', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' }, }), { target: 'next-turn', wakeup: false, }) const release = agent.reserveTurnAdmission() expect(release).toBeDefined() release?.() await agent.whenIdle() expect(adapter.requests).toHaveLength(0) }) it('makes whenIdle() wait for release without spinning on a settled promise', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const machine = agent as Agent & { done: Promise } let backing = machine.done let reads = 0 Object.defineProperty(agent, 'done', { configurable: true, get(): Promise { reads += 1 return backing }, set(value: Promise) { backing = value }, }) const release = agent.reserveTurnAdmission() prompt(agent, 'waiting for the reservation') let settled = false const idle = agent.whenIdle().then(() => { settled = true }) for (let tick = 0; tick < 5; tick += 1) { await new Promise((resolve) => { setTimeout(resolve, 1) }) } expect(settled).toBe(false) expect(reads).toBeLessThanOrEqual(2) release?.() await idle expect(settled).toBe(true) expect(adapter.requests).toHaveLength(1) }) it('resolves whenIdle() after release with nothing queued', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const release = agent.reserveTurnAdmission() let settled = false const idle = agent.whenIdle().then(() => { settled = true }) await new Promise((resolve) => { setTimeout(resolve, 5) }) expect(settled).toBe(false) release?.() await idle expect(agent.status).toBe('idle') }) it('lets cancellation discard held prompts and keeps the boundary quiet', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const inbox = recordInbox(ctx) const release = agent.reserveTurnAdmission() prompt(agent, 'discarded while held') agent.cancel({ kind: 'user' }) expect(inbox.events).toEqual([ 'enqueue:queued:discarded while held', 'discard:discarded while held', ]) expect(inbox.discarded).toEqual(inbox.enqueued) expect(inbox.dequeued).toEqual([]) release?.() await agent.whenIdle() expect(adapter.requests).toHaveLength(0) expect(agent.session.events).toHaveLength(0) }) it('disposes the agent without waiting for the reservation to be released', async () => { const adapter = new MockAdapter([]) const ctx = await harness(adapter) const handle = await ctx.agents.create({ sessionId: SessionId('a1'), agentOptions: { provider: 'mock', model: 'mock' }, }) const { agent } = handle const release = agent.reserveTurnAdmission() prompt(agent, 'discarded by disposal') await handle.dispose() expect(ctx.agents.list()).toEqual([]) expect(adapter.requests).toHaveLength(0) release?.() }) })