refactor: hide the concrete agent loop

This commit is contained in:
Tianyi Cui
2026-07-14 02:32:35 +08:00
parent 31c29609d1
commit 225796c90d
40 changed files with 234 additions and 217 deletions

View File

@@ -4,11 +4,15 @@ 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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -21,7 +25,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -32,7 +36,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise<void> {
function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === expected) {
@@ -43,11 +47,11 @@ function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopA
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
describe('ReactLoopAgent', () => {
describe('Agent', () => {
it('rejects access before context binding and a second driver for one session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -70,7 +74,7 @@ describe('ReactLoopAgent', () => {
expect(agent.options).toBe(options)
expect(agent.id).toBe('owned-bindings')
expect(agent.session.id).toBe(agent.id)
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/)
await ctx.fiber.dispose()
})
@@ -78,14 +82,14 @@ describe('ReactLoopAgent', () => {
it('send() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
})
@@ -93,14 +97,14 @@ describe('ReactLoopAgent', () => {
it('steer() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
})
@@ -108,14 +112,14 @@ describe('ReactLoopAgent', () => {
it('inject() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
})
@@ -251,7 +255,7 @@ describe('ReactLoopAgent', () => {
})
it('disposer is idempotent (double-stop)', async () => {
// Create a bare ReactLoopAgent and start it through the package-internal
// Create a bare Agent and start it through the package-internal
// test seam. Then call its disposer twice — the second call hits the
// early-return branch.
const ctx = new Context()
@@ -367,7 +371,7 @@ describe('ReactLoopAgent', () => {
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// while running (not the fast path), then the disposer settles it and chains
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
// `done` (loop exit), not an eager resolve. A bare Agent + direct
// internal driver disposer keeps the emit synchronous.
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -401,7 +405,7 @@ describe('ReactLoopAgent', () => {
// it. Regression for the round-3 whenIdle finding.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -422,7 +426,7 @@ describe('ReactLoopAgent', () => {
// only after `done` — i.e. the loop has actually exited.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -430,7 +434,7 @@ describe('ReactLoopAgent', () => {
await new Promise(r => setTimeout(r, 30))
let doneResolved = false
void agent.done.then(() => { doneResolved = true })
void driverDone(agent).then(() => { doneResolved = true })
await fiber.dispose() // sets status disposed, aborts, drains the loop
expect(agent.status).toBe('disposed')

View File

@@ -16,11 +16,15 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -33,12 +37,12 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
/** Resolve on the agent's next idle transition (event-based, not status poll). */
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -47,7 +51,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
}
/** All user-message texts recorded in the log (to assert what actually ran). */
function userTexts(agent: ReactLoopAgent): string[] {
function userTexts(agent: Agent): string[] {
return agent.session.events
.filter(e => e.type === 'user/message')
.flatMap(e => e.type === 'user/message' ? e.data.content : [])
@@ -207,7 +211,7 @@ describe('Agent.cancel()', () => {
sessionId: SessionId('dispose-prefix-session'),
agentOptions: { model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const agent = handle.agent
let disposalDone: Promise<void> | undefined
let streamed = false
@@ -220,7 +224,7 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await new Promise(resolve => setTimeout(resolve, 0))
await disposalDone
await agent.done
await driverDone(agent)
// No step opened, no model call ran, and the turn closed disposed.
expect(streamed).toBe(false)
@@ -337,7 +341,7 @@ describe('Agent.cancel()', () => {
sessionId: SessionId('dispose-step-start-session'),
agentOptions: { model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const agent = handle.agent
let disposalDone: Promise<void> | undefined
let streamed = false
@@ -348,7 +352,7 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await disposalDone
await agent.done
await driverDone(agent)
expect(streamed).toBe(false)
expect(adapter.requests).toHaveLength(0)

View File

@@ -7,16 +7,16 @@ 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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -57,7 +57,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
const a1 = ctx1.agents.list()[0] as ReactLoopAgent
const a1 = ctx1.agents.list()[0] as Agent
expect(a1.id).toBe(a1.session.id)
expect(a1.session.id).toMatch(idPattern)
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
@@ -76,7 +76,7 @@ describe('config-driven session id', () => {
await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
const a2 = ctx2.agents.list()[0] as ReactLoopAgent
const a2 = ctx2.agents.list()[0] as Agent
expect(a2.id).toBe(a2.session.id)
expect(a2.session.id).toMatch(idPattern)
expect(a2.session.id).not.toBe(a1.session.id)
@@ -100,7 +100,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -119,10 +119,10 @@ describe('config-driven session id', () => {
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
// The deferred resume runs on a microtask after the backend is available.
let resumed: ReactLoopAgent | undefined
let resumed: Agent | undefined
for (let i = 0; i < 50 && !resumed; i++) {
await new Promise(r => setTimeout(r, 5))
resumed = ctx2.agents.get(SessionId('sticky-1')) as ReactLoopAgent | undefined
resumed = ctx2.agents.get(SessionId('sticky-1'))
}
expect(resumed).toBeDefined()
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),

View File

@@ -5,11 +5,15 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -22,7 +26,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -33,7 +37,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -213,7 +217,7 @@ describe('disposed vs aborted branching', () => {
it('handles dispose during model streaming producing reason "disposed"', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -224,7 +228,7 @@ describe('disposed vs aborted branching', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose() // dispose during hang
await agent.done
await driverDone(agent)
// The review-fixes test for 'HIGH: disposed status' already covers
// this assertion path. The reason is 'disposed' because isDisposed() is

View File

@@ -4,9 +4,9 @@ import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -41,11 +41,11 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
function events(agent: ReactLoopAgent): SessionEvent[] {
function events(agent: Agent): SessionEvent[] {
return [...agent.session.events]
}

View File

@@ -4,11 +4,15 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter, persona = '') {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -26,7 +30,7 @@ async function harness(adapter: MockAdapter, persona = '') {
* invoke this right after send(), when the loop hasn't woken yet (status is
* still 'idle' synchronously), so polling the current status would lie.
*/
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -37,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -175,7 +179,7 @@ describe('agent loop', () => {
agentOptions: { model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const agent = handle.agent
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -911,7 +915,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -922,7 +926,7 @@ describe('agent loop', () => {
expect(agent.status).toBe('running')
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
@@ -942,7 +946,7 @@ describe('agent loop', () => {
})
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agents.list()[0]! as ReactLoopAgent
const agent = ctx.agents.list()[0]!
expect(agent).toBeDefined()
expect(agent.id).toBe(agent.session.id)
expect(agent.id).toMatch(/^config-agent-session-/)
@@ -965,7 +969,7 @@ describe('agent loop', () => {
agents: [{ id: 'config-agent', model: 'mock', cwd: '/work/project' }],
})
const agent = ctx.agents.list()[0]! as ReactLoopAgent
const agent = ctx.agents.list()[0]!
expect(agent.session.header.cwd).toBe('/work/project')
})

View File

@@ -17,9 +17,9 @@ import { LlmAdapter } 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 AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import fc from 'fast-check'
/** A never-exhausting adapter: every model call returns the same short reply. */
@@ -48,7 +48,7 @@ async function harness() {
}
/** Resolve on the agent's next transition to idle (event-based, not polled). */
function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function nextIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -61,7 +61,7 @@ function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
/** Record every status transition for the legal-machine assertion. Returns
* the seen list plus a disposer for the listener (per the registry convention). */
function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } {
function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } {
const seen: string[] = []
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent) seen.push(status)
@@ -69,13 +69,13 @@ function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; di
return { seen, dispose }
}
function userMessageTexts(agent: ReactLoopAgent): string[] {
function userMessageTexts(agent: Agent): string[] {
return agent.session.events
.filter(e => e.type === 'user/message')
.map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
}
function turnNumbers(agent: ReactLoopAgent): number[] {
function turnNumbers(agent: Agent): number[] {
return agent.session.events
.filter(e => e.type === 'turn/start')
.map(e => (e.data as { turn: number }).turn)

View File

@@ -15,9 +15,9 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, persona = 'stable base') {
@@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -43,7 +43,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -229,7 +229,7 @@ describe('request stability across the loop', () => {
seed: [...agent.session.events],
agentOptions: { model: 'mock' },
})
const agent2 = handle.agent as ReactLoopAgent
const agent2 = handle.agent
send(agent2, 'second')
await waitForIdle(ctx2, agent2)

View File

@@ -8,10 +8,10 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = []
@@ -51,7 +51,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
return root
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -124,7 +124,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: create a no-cwd session and run a turn.
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -140,7 +140,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent
expect(a2.session.header.cwd).toBeUndefined()
await ctx2.fiber.dispose()
})
@@ -151,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = []
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
expect(sources1).toEqual(['startup'])
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
@@ -443,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent
expect(a2.session.header.parentSession).toBe('parent-sess')
expect(a2.session.header.cwd).toBe('/w')
expect(a2.session.header.seedLength).toBe(seed.length)
@@ -457,7 +457,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// disk, since a crash before the next turn would otherwise lose it.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -482,7 +482,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// drop it on reload (the bug this guards).
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -500,7 +500,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose()
@@ -510,7 +510,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: run one full turn, persisting it.
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
@@ -530,7 +530,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
expect(a2.session.events.length).toBe(events1.length)

View File

@@ -4,13 +4,17 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
/** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
async function harness(adapter: MockAdapter) {
@@ -25,7 +29,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -36,7 +40,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -324,7 +328,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -337,7 +341,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(statuses).toEqual(['running', 'disposed'])
expect(reasons).toEqual([{ kind: 'disposed' }])
@@ -347,7 +351,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -359,7 +363,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done // must not hang
await driverDone(agent) // must not hang
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw
@@ -697,7 +701,7 @@ describe('turn and step boundary recovery', () => {
}
/** Count turn/step boundary events for balance assertions. */
function boundaryCounts(agent: ReactLoopAgent) {
function boundaryCounts(agent: Agent) {
const e = [...agent.session.events]
return {
turnStart: e.filter(x => x.type === 'turn/start').length,
@@ -871,7 +875,7 @@ describe('turn and step boundary recovery', () => {
// balanced with reason disposed (no error event for a disposal).
const adapter = new MockAdapter(['hang'])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -882,7 +886,7 @@ describe('turn and step boundary recovery', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose() // dispose during the hanging step
await agent.done
await driverDone(agent)
const e = [...agent.session.events]
const turnStarts = e.filter(x => x.type === 'turn/start').length
@@ -900,7 +904,7 @@ describe('turn and step boundary recovery', () => {
// and must preserve reason=disposed rather than rewrite it as a plugin error.
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -919,7 +923,7 @@ describe('turn and step boundary recovery', () => {
ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error))
send(agent, 'go')
await agent.done
await driverDone(agent)
const e = [...agent.session.events]
// Balanced: one turn/start, one turn/end carrying disposed (NOT error).
@@ -1148,7 +1152,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
// calls stop() synchronously, setting status=disposed), then release the
// block. The loop must check isDisposed() after assembly and end the turn
// `disposed` — no LLM call. Don't await fiber.dispose() before releasing
// the blocker: the dispose chain awaits agent.done, which hangs until the
// the blocker: the dispose chain awaits driverDone(agent), which hangs until the
// loop unblocks.
const adapter = new MockAdapter(['hang'])
let releaseAssemble!: () => void
@@ -1170,7 +1174,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
return next()
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1183,15 +1187,15 @@ describe('disposal and cancellation during pre-step assembly', () => {
await new Promise(r => setTimeout(r, 50))
// Start disposal — stop() sets status=disposed synchronously, then the
// disposer's await agent.done hangs because the loop is blocked in the
// disposer's await driverDone(agent) hangs because the loop is blocked in the
// waterfall. Do NOT await yet; release the blocker first.
const disposalDone = fiber.dispose()
// Now release the blocked waterfall — the loop unblocks, checks
// isDisposed(), and exits, which resolves agent.done and disposalDone.
// isDisposed(), and exits, which resolves driverDone(agent) and disposalDone.
releaseAssemble()
await disposalDone
await agent.done
await driverDone(agent)
unlisten()
const e = [...agent.session.events]
@@ -1226,7 +1230,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
return next()
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1241,7 +1245,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
releaseAssemble()
await waitForIdle(ctx, agent)
await fiber.dispose()
await agent.done
await driverDone(agent)
unlisten()
const e = [...agent.session.events]
@@ -1281,7 +1285,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await blocker
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1296,7 +1300,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
const disposalDone = fiber.dispose()
releasePreStep()
await disposalDone
await agent.done
await driverDone(agent)
// After the pre-step seam finishes, the post-seam cancel/dispose check
// catches disposal. The step was never opened, no LLM call was made.
@@ -1333,7 +1337,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await blocker
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1348,7 +1352,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
releasePreStep()
await waitForIdle(ctx, agent)
await fiber.dispose()
await agent.done
await driverDone(agent)
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
@@ -1383,7 +1387,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
return next()
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1394,7 +1398,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
const disposalDone = fiber.dispose()
releaseAssemble()
await disposalDone
await agent.done
await driverDone(agent)
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)

View File

@@ -8,7 +8,7 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok'
return (await harnessWithLoop(adapter)).ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -718,7 +718,7 @@ describe('agent scope lifecycle', () => {
const ctx = await harness()
let ownerCtx!: Context
let creating!: ReturnType<typeof ctx.agents.create>
let announced!: ReactLoopAgent
let announced!: Agent
const statuses: string[] = []
let scopeDisposed = false
let observerSawLive = false
@@ -727,7 +727,7 @@ describe('agent scope lifecycle', () => {
})
ctx.on('agent/session-start', (agent) => {
if (agent.id !== SessionId('session-start-dispose-s')) return
announced = agent as ReactLoopAgent
announced = agent
disposeCurrentLifecycle(ownerCtx)
})
ctx.on('agent/session-start', (agent) => {

View File

@@ -14,9 +14,9 @@ import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-ses
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
@@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {

View File

@@ -4,9 +4,9 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -23,7 +23,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
return ctx
}
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
function send(agent: Agent, text = 'go'): Promise<void> {
agent.send([{ type: 'text', text }])
return agent.whenIdle()
}