Merge origin/master into worktree/explicit-turn-signal
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
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, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
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)
|
||||
@@ -17,13 +21,12 @@ async function harness(adapter: MockAdapter) {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
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') {
|
||||
@@ -34,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 }])
|
||||
}
|
||||
|
||||
@@ -42,7 +45,7 @@ describe('inbox acceptance', () => {
|
||||
it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let queued = 0
|
||||
ctx.on('agent/queued', () => { queued += 1 })
|
||||
|
||||
@@ -82,7 +85,7 @@ describe('tool JSON parse', () => {
|
||||
return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'use tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -115,7 +118,7 @@ describe('tool JSON parse', () => {
|
||||
return [{ type: 'text', text: 'ran with empty args' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'use tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -128,7 +131,7 @@ describe('toError normalization', () => {
|
||||
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
@@ -143,18 +146,28 @@ describe('toError normalization', () => {
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
send(agent, 'fails before turn start')
|
||||
send(agent, 'survives as the next item')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const starts = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const ends = agent.session.events.filter(event => event.type === 'turn/end')
|
||||
const messages = agent.session.events.filter(event => event.type === 'user/message')
|
||||
expect(starts).toHaveLength(1)
|
||||
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1)
|
||||
expect(ends).toHaveLength(1)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([
|
||||
{ type: 'text', text: 'survives as the next item' },
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
|
||||
const adapter = new MockAdapter([textResponse('irrelevant')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, _next) => {
|
||||
@@ -182,7 +195,7 @@ describe('coded error data emission', () => {
|
||||
it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, next) => {
|
||||
@@ -214,9 +227,9 @@ 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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -225,14 +238,14 @@ 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)
|
||||
|
||||
// Disposal wins abort classification because the error path checks it first.
|
||||
expect(reasons).toContainEqual({ kind: 'disposed' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('structured tool error propagation (the runtime-validation RFC, part 2)', () => {
|
||||
describe('structured tool error propagation (the runtime-validation Agent Note, part 2)', () => {
|
||||
it('forwards a tool HarnessError onto the tool/result session event', async () => {
|
||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
// First model turn calls the tool; second turn (after the tool result is
|
||||
@@ -242,7 +255,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2)
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'boom',
|
||||
description: 'always fails',
|
||||
|
||||
Reference in New Issue
Block a user