Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-30-interception-seams.md
#	docs/config-catalog.md
#	docs/cookbook/adding-a-tool.i18n.yaml
#	docs/cookbook/adding-a-tool.md
#	docs/cookbook/adding-a-tool.zh.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/tools.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/bash/tool-bash/src/index.ts
#	packages/core/agent-loop/src/tool-calls.ts
#	packages/core/agent-loop/tests/cancel.spec.ts
#	packages/core/agent-loop/tests/contract-regressions.spec.ts
#	packages/core/agent-loop/tests/tool-calls.spec.ts
#	packages/core/tools/README.md
#	packages/core/tools/src/index.ts
#	packages/core/tools/tests/code-mode.spec.ts
#	packages/core/tools/tests/tools.spec.ts
#	packages/fs/tool-fs-search/tests/integration.spec.ts
#	packages/fs/tool-fs-search/tests/tools.spec.ts
#	packages/fs/tool-fs/tests/integration.spec.ts
#	packages/mcp/mcp-client/src/tools.ts
#	packages/timeout/timeout-policy/tests/timeout-policy.spec.ts
#	packages/web/tool-web/tests/integration.spec.ts
#	packages/web/tool-web/tests/tool-web.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-21 23:39:03 +08:00
194 changed files with 3387 additions and 1222 deletions

View File

@@ -9,6 +9,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
const testToolSignal = new AbortController().signal
interface Harness {
ctx: Context
agentsFiber: Fiber
@@ -142,6 +144,80 @@ describe('AgentLoop initiator scope', () => {
await ctx.fiber.dispose()
})
it('keeps initiator identity minimal while one explicit signal spans each turn seam', async () => {
const adapter = new MockAdapter([
toolCallResponse('observe-call', 'observe', {}),
textResponse('first done'),
textResponse('second done'),
])
const { ctx } = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' })
let signals: AbortSignal[] = []
const capture = (signal: AbortSignal | undefined): void => {
if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
expect(ctx.agents.requireInitiator()).toBe(agent)
signals.push(signal)
}
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
if (context.agent === agent) capture(context.signal)
return next()
})
ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/pre-step', (subject, _turn, _step, signal) => {
if (subject === agent) capture(signal)
})
ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/turn-stop', (subject, _turn, signal) => {
if (subject === agent) capture(signal)
})
ctx.tools.register(defineContentToolFixture({
name: 'observe',
description: 'observe explicit turn state',
parameters: {},
execute: async (_args, exec) => {
capture(exec.signal)
return [{ type: 'text', text: 'observed' }]
},
}))
const firstIdle = waitForIdle(ctx, agent)
send(agent, 'first')
await firstIdle
const firstSignal = signals[0]
expect(firstSignal).toBeDefined()
expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
signals = []
const secondIdle = waitForIdle(ctx, agent)
send(agent, 'second')
await secondIdle
const secondSignal = signals[0]
expect(secondSignal).toBeDefined()
expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
expect(secondSignal).not.toBe(firstSignal)
expect(ctx.agents.currentInitiator()).toBeUndefined()
await ctx.fiber.dispose()
})
it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => {
const adapter = new MockAdapter([
toolCallResponse('spawn', 'spawn-child', {}),
@@ -239,6 +315,7 @@ describe('AgentLoop initiator scope', () => {
}))
const direct = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('direct'),
name: 'agentless-probe',
arguments: {},

View File

@@ -345,7 +345,7 @@ describe('Agent', () => {
expect(settled).toBe(false)
await waitForStatus(ctx, agent, 'running')
agent.cancel('done')
agent.cancel({ kind: 'user' })
await idle
expect(settled).toBe(true)
expect(agent.status).toBe('idle')

View File

@@ -1,9 +1,8 @@
/**
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it
* clears queued + steering work, aborts an in-flight step, and drops a turn about to start —
* whereas a bare step abort (the loop's private `AbortController`) kills only the current step
* and leaves the queue intact. The suite covers every landing window plus marker
* reset and `whenIdle()` quiescence.
* clears queued + steering work, aborts the active turn, and drops work not yet claimed by the
* driver without leaking cancellation into a replacement prompt. The suite covers every landing
* window plus signal reset and `whenIdle()` quiescence.
* @module dsh-agent-loop/tests/cancel
*/
@@ -12,7 +11,7 @@ import { Context } from 'cordis'
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, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
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'
@@ -61,22 +60,22 @@ describe('Agent.cancel()', () => {
const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' })
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = []
ctx.on('agent/cancel-requested', (subject, reason) => {
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject !== agent) return
seen.push(`first:${reason}`)
seen.push(`first:${cause.kind}`)
subject.send([{ type: 'text', text: 'queued by cancel observer' }])
throw new Error('observer failed')
})
ctx.on('agent/cancel-requested', (subject, reason) => {
if (subject === agent) seen.push(`second:${reason}`)
ctx.on('agent/cancel-requested', (subject, cause) => {
if (subject === agent) seen.push(`second:${cause.kind}`)
})
send(agent, 'drop me')
agent.cancel()
await new Promise(resolve => setTimeout(resolve, 30))
agent.cancel('idle no-op')
agent.cancel({ kind: 'parent' })
expect(seen).toEqual(['first:cancelled', 'second:cancelled'])
expect(seen).toEqual(['first:user', 'second:user'])
expect(userTexts(agent)).toEqual([])
expect(adapter.requests).toHaveLength(0)
expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
@@ -89,7 +88,7 @@ describe('Agent.cancel()', () => {
// The loop is parked at the idle wait with nothing queued. A cancel here must
// NOT arm the marker — otherwise the next legitimate prompt would be dropped.
agent.cancel('nothing to cancel')
agent.cancel({ kind: 'user' })
send(agent, 'real prompt')
await waitForIdle(ctx, agent)
@@ -108,7 +107,7 @@ describe('Agent.cancel()', () => {
// resumed). Cancel in that pre-step window: the queued turn must not run.
send(agent, 'drop me first')
send(agent, 'drop me second')
agent.cancel('pre-step')
agent.cancel({ kind: 'user' })
// Give the loop a chance to wake and process the cancel.
await new Promise(r => setTimeout(r, 30))
@@ -157,7 +156,7 @@ describe('Agent.cancel()', () => {
// drops the turn before it runs; the skip path must settle it directly.
send(agent, 'q')
const idle = agent.whenIdle()
agent.cancel('pre-step')
agent.cancel({ kind: 'user' })
// Must resolve (not hang). A timeout makes the failure a clear test failure.
await Promise.race([
@@ -186,7 +185,7 @@ describe('Agent.cancel()', () => {
// before its resolved waitForQueued continuation checks cancellation.
queueMicrotask(() => {
queueMicrotask(() => {
agent.cancel('between turns')
agent.cancel({ kind: 'user' })
cancelled.resolve(undefined)
})
})
@@ -236,7 +235,7 @@ describe('Agent.cancel()', () => {
ctx.on('agent/error', (subject, _turn, _step, error) => {
if (subject !== agent || error.message !== 'first flush failed') return
queueMicrotask(() => {
queueMicrotask(() => { agent.cancel('between turns') })
queueMicrotask(() => { agent.cancel({ kind: 'user' }) })
})
})
@@ -277,7 +276,7 @@ describe('Agent.cancel()', () => {
requests: adapter.requests.length,
turns: agent.session.events.filter(event => event.type === 'turn/start').length,
}))
agent.cancel('idle listener')
agent.cancel({ kind: 'user' })
replacementRegistered.resolve(undefined)
})
@@ -307,7 +306,7 @@ describe('Agent.cancel()', () => {
ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return
send(agent, 'cancelled replacement')
agent.cancel('idle listener')
agent.cancel({ kind: 'user' })
send(agent, 'surviving replacement')
replacementIdle = agent.whenIdle()
replacementRegistered.resolve(undefined)
@@ -334,16 +333,16 @@ describe('Agent.cancel()', () => {
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
send(agent, 'queued tail')
agent.cancel('mid-step')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
expect(userTexts(agent)).toEqual(['go'])
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(adapter.requests).toHaveLength(1)
})
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
it('cancel() with no cause defaults to user when aborting an active turn', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -353,10 +352,10 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.cancel() // no reason → default 'cancelled'
agent.cancel()
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
@@ -378,7 +377,7 @@ describe('Agent.cancel()', () => {
const agent = ctx.agentLoop.create(SessionId('cancel-after-assistant-message'), { provider: 'mock', model: 'mock' })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/message') {
agent.cancel('cancelled after assistant message')
agent.cancel({ kind: 'user' })
}
})
@@ -390,14 +389,14 @@ describe('Agent.cancel()', () => {
dispose()
expect(executions).toBe(0)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled after assistant message' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
const call = agent.session.events.find(event => event.type === 'tool/call')
const result = agent.session.events.find(event => event.type === 'tool/result')
expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
callId: 'c1',
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
send(agent, 'continue safely')
@@ -407,7 +406,7 @@ describe('Agent.cancel()', () => {
.find(block => block.type === 'tool-result')
expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
expect(reasons).toEqual([
{ kind: 'aborted', reason: 'cancelled after assistant message' },
{ kind: 'aborted' },
{ kind: 'completed' },
])
})
@@ -420,7 +419,7 @@ describe('Agent.cancel()', () => {
// First turn hangs; cancel it mid-step.
send(agent, 'first')
await new Promise(r => setTimeout(r, 30))
agent.cancel('cancel first')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
// The marker must have been reset after the cancelled turn — a fresh prompt
@@ -445,7 +444,7 @@ describe('Agent.cancel()', () => {
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => {
agent.cancel('from prefix composition')
agent.cancel({ kind: 'user' })
return next()
})
@@ -456,7 +455,7 @@ describe('Agent.cancel()', () => {
await waitForIdle(ctx, agent)
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from prefix composition' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('disposal from inside the agent/session-prefix waterfall ends the turn disposed (prefix-composition window)', async () => {
@@ -508,7 +507,7 @@ describe('Agent.cancel()', () => {
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => {
compositions += 1
if (compositions === 1) {
agent.cancel('mid-composition')
agent.cancel({ kind: 'user' })
return next()
}
return [opener, ...await next()]
@@ -536,7 +535,7 @@ describe('Agent.cancel()', () => {
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start')
if (session === agent.session && event.type === 'turn/start') agent.cancel({ kind: 'user' })
})
const reasons: TurnEndReason[] = []
@@ -547,10 +546,10 @@ describe('Agent.cancel()', () => {
dispose()
// No step streamed (the model never ran), and the turn ended aborted with
// the CALLER's reason — the marker carries `cancel(reason)` through even
// the caller's cause — the marker carries `cancel(cause)` through even
// though no AbortController observed it in this window.
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
@@ -565,7 +564,7 @@ describe('Agent.cancel()', () => {
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'step/start') agent.cancel('from step-start')
if (session === agent.session && event.type === 'step/start') agent.cancel({ kind: 'user' })
})
const reasons: TurnEndReason[] = []
@@ -575,10 +574,10 @@ describe('Agent.cancel()', () => {
await waitForIdle(ctx, agent)
dispose()
// No step streamed, the turn ended aborted with the caller's reason, and the
// No step streamed, the turn ended with the coarse aborted outcome, and the
// log is balanced (the open step was closed by the cancel branch).
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from step-start' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
const types = agent.session.events.map(e => e.type)
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
})
@@ -636,11 +635,11 @@ describe('Agent.cancel()', () => {
})
let continued = false
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
if (subject === agent && !continued) {
continued = true
agent.cancel('from continuation')
return { action: 'continue' as const } // vote to continue — the post-waterfall marker check must override
agent.cancel({ kind: 'user' })
return { action: 'continue' as const }
}
return next()
})
@@ -649,10 +648,9 @@ describe('Agent.cancel()', () => {
await waitForIdle(ctx, agent)
// Only ONE step ran (the second was cancelled in the continuation window),
// and the turn ended aborted with the CALLER's reason (carried by the
// marker, since the finished step's AbortController was already cleared).
// and the shared turn signal classified the durable outcome as aborted.
expect(steps).toBe(1)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from continuation' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
@@ -665,7 +663,7 @@ describe('Agent.cancel()', () => {
let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'running') agent.cancel('from running listener')
if (subject === agent && status === 'running') agent.cancel({ kind: 'user' })
})
send(agent, 'go')
@@ -688,7 +686,7 @@ describe('Agent.cancel()', () => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'running' || replaced) return
replaced = true
agent.cancel('drop A')
agent.cancel({ kind: 'user' })
send(agent, 'B')
})
@@ -713,7 +711,7 @@ describe('Agent.cancel()', () => {
send(agent, 'A') // queues A (status still idle, loop microtask pending)
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
agent.cancel('drop A') // arms marker, clears A
agent.cancel({ kind: 'user' }) // arms marker, clears A
send(agent, 'B') // B races in before the loop resumes
// whenIdle() must resolve only after B's turn fully ran — by which point B's user message
@@ -736,7 +734,7 @@ describe('Agent.cancel()', () => {
// Steer (joins the running turn's steering FIFO), then cancel: the steering
// must be dropped, NOT re-enqueued as a new queued turn.
agent.steer([{ type: 'text', text: 'steer text' }])
agent.cancel('cancel with steering')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
// After the cancelled turn settles, the agent is idle with NO follow-up turn
@@ -752,4 +750,228 @@ describe('Agent.cancel()', () => {
.flatMap(b => b.type === 'text' ? [b.text] : [])
expect(flat).not.toContain('steer text')
})
it('keeps replacement work queued synchronously by an abort observer', async () => {
const adapter = new MockAdapter(['hang', textResponse('replacement reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('abort-observer-replacement'), { provider: 'mock', model: 'mock' })
send(agent, 'original')
await expect.poll(() => adapter.requests.length).toBe(1)
const signal = adapter.requests[0]?.signal
if (signal === undefined) throw new Error('model request omitted its turn signal')
signal.addEventListener('abort', () => { send(agent, 'replacement') }, { once: true })
const idle = waitForIdle(ctx, agent)
agent.cancel({ kind: 'user' })
await Promise.race([
idle,
new Promise((_resolve, reject) => {
setTimeout(() => {
reject(new Error(`replacement did not settle: ${JSON.stringify({
status: agent.status,
requests: adapter.requests.length,
users: userTexts(agent),
events: agent.session.events.map(event => event.type),
})}`))
}, 1000)
}),
])
expect(adapter.requests).toHaveLength(2)
expect(userTexts(agent)).toEqual(['original', 'replacement'])
const reasons = agent.session.events
.filter(event => event.type === 'turn/end')
.map(event => event.type === 'turn/end' ? event.data.reason : undefined)
expect(reasons).toEqual([{ kind: 'aborted' }, { kind: 'completed' }])
})
it('keeps the first typed cause for an active turn and detaches the runtime reason', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' })
const supplied: { kind: 'parent' | 'user' } = { kind: 'parent' }
send(agent, 'go')
await expect.poll(() => adapter.requests.length).toBe(1)
agent.cancel(supplied)
supplied.kind = 'user'
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
const runtimeReason: unknown = adapter.requests[0]?.signal?.reason
expect(runtimeReason).toEqual({ kind: 'parent' })
expect(runtimeReason).not.toBe(supplied)
expect(Object.isFrozen(runtimeReason)).toBe(true)
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
})
it('retires turn cancellation before terminal publication and a blocked durability flush', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('terminal-cancellation-authority'), { provider: 'mock', model: 'mock' })
const flushStarted = Promise.withResolvers<undefined>()
const releaseFlush = Promise.withResolvers<undefined>()
let abortedDuringTurnEnd: boolean | undefined
let cancelNotifications = 0
ctx.on('agent/cancel-requested', (subject) => {
if (subject === agent) cancelNotifications += 1
})
ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'turn/end') return
const signal = adapter.requests[0]?.signal
if (signal === undefined) throw new Error('model request omitted its turn signal')
agent.cancel({ kind: 'user' })
abortedDuringTurnEnd = signal.aborted
})
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushStarted.resolve(undefined)
await releaseFlush.promise
})
send(agent, 'finish before persistence drains')
await flushStarted.promise
const signal = adapter.requests[0]?.signal
if (signal === undefined) throw new Error('model request omitted its turn signal')
const idle = agent.whenIdle()
agent.cancel({ kind: 'user' })
expect(abortedDuringTurnEnd).toBe(false)
expect(signal.aborted).toBe(false)
expect(cancelNotifications).toBe(0)
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'completed' } },
})
releaseFlush.resolve(undefined)
await idle
expect(agent.status).toBe('idle')
})
it('records disposed when lifecycle teardown races an already-requested cancel', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('cancel-dispose-race'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { agent } = handle
send(agent, 'go')
await expect.poll(() => adapter.requests.length).toBe(1)
agent.cancel({ kind: 'user' })
await handle.dispose()
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
})
it.each([
'prompt-submit',
'system-prompt',
'session-prefix',
'pre-step',
'request',
'step-result',
'post-step',
'turn-continuation',
'turn-stop',
'tool',
] as const)('lets a cooperative %s boundary settle from the explicit turn signal', async (stage) => {
const adapter = new MockAdapter(stage === 'tool'
? [toolCallResponse('blocked-tool', 'blocked', {})]
: [textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId(`cooperative-${stage}`), { provider: 'mock', model: 'mock' })
const started = Promise.withResolvers<undefined>()
const blockUntilAbort = async (signal: AbortSignal): Promise<void> => {
started.resolve(undefined)
if (signal.aborted) return
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
switch (stage) {
case 'prompt-submit':
ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'system-prompt':
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
if (context.agent === agent) {
if (context.signal === undefined) throw new Error('turn assembly omitted its signal')
await blockUntilAbort(context.signal)
}
return next()
})
break
case 'session-prefix':
ctx.on('agent/session-prefix', async (subject, _prefix, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'pre-step':
ctx.on('agent/pre-step', async (subject, _turn, _step, signal) => {
if (subject === agent) await blockUntilAbort(signal)
})
break
case 'request':
ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'step-result':
ctx.on('agent/step-result', async (subject, _turn, _step, _message, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'post-step':
ctx.on('agent/post-step', async (subject, _turn, _step, signal) => {
if (subject !== agent) return
await blockUntilAbort(signal)
throw new Error('post-step failed after cancellation')
})
break
case 'turn-continuation':
ctx.on('agent/turn-continuation', async (subject, _turn, _decision, signal, next) => {
if (subject === agent) await blockUntilAbort(signal)
return next()
})
break
case 'turn-stop':
ctx.on('agent/turn-stop', async (subject, _turn, signal) => {
if (subject === agent) await blockUntilAbort(signal)
})
break
case 'tool':
ctx.tools.register(defineContentToolFixture({
name: 'blocked',
description: 'wait for cancellation',
parameters: {},
execute: async (_args, exec) => {
if (exec.signal === undefined) throw new Error('tool execution omitted its signal')
await blockUntilAbort(exec.signal)
return [{ type: 'text', text: 'cancelled' }]
},
}))
break
}
send(agent, 'go')
await started.promise
const idle = waitForIdle(ctx, agent)
agent.cancel({ kind: 'user' })
await idle
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
await ctx.fiber.dispose()
})
})

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
@@ -73,7 +73,7 @@ describe('session log records what agent/step-result actually produced', () => {
// Plugin rewrites the message: replaces the text AND adds a tool call.
let rewritten = false
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, next) => {
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal, next) => {
if (rewritten) return next()
rewritten = true
return {
@@ -214,7 +214,7 @@ describe('successful provider completion survives agent/step-result failure', ()
})
describe('abort during tool execution ends the turn', () => {
it('balances an aborted tool batch through context, steering, and post-step before closing', async () => {
it('balances a cancelled tool batch through context and post-step before closing', async () => {
const adapter = new MockAdapter([
// model asks for two tool calls in one step
[
@@ -239,8 +239,7 @@ describe('abort during tool execution ends the turn', () => {
[{ type: 'text', text: 'steering before abort' }],
{ source: { kind: 'plugin', plugin: 'abort-test' } },
)
// Exercise bare step abort without `cancel()` clearing queued work.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
}))
@@ -269,7 +268,10 @@ describe('abort during tool execution ends the turn', () => {
case 'assistant/message': order.push('assistant/message'); break
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
case 'tool/result': {
const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
const outcome = event.data.error?.code === TOOL_ABORTED
|| event.data.error?.code === TOOL_ABORTED_BEFORE_DISPATCH
? 'aborted'
: 'completed'
order.push(`tool/result:${event.data.callId}:${outcome}`)
break
}
@@ -300,25 +302,29 @@ describe('abort during tool execution ends the turn', () => {
expect(order).toEqual([
'assistant/message',
'tool/call:c1',
'tool/result:c1:real',
'tool/result:c1:aborted',
'tool/call:c2',
'tool/result:c2:synthetic-aborted',
'tool/result:c2:aborted',
'context/message',
'steering/message',
'agent/post-step',
'step/end',
'turn/end:aborted',
])
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
const calls = agent.session.events.filter(event => event.type === 'tool/call')
const results = agent.session.events.filter(event => event.type === 'tool/result')
expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')])
expect(results).toHaveLength(2)
expect(results[0]!.data).toMatchObject({ callId: CallId('c1'), isError: false })
expect(results[0]!.data).toMatchObject({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: tool call aborted' }],
isError: true,
error: { name: 'AbortError', code: TOOL_ABORTED },
})
expect(results[1]!.data).toMatchObject({
callId: CallId('c2'),
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
})
@@ -332,7 +338,7 @@ describe('abort during tool execution ends the turn', () => {
parameters: {},
async execute() {
agent.inject([{ type: 'text', text: 'accepted before abort' }], { source: { kind: 'plugin', plugin: 'test' } })
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
}))
@@ -385,7 +391,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute() {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'aborted' }]
},
}))
@@ -478,7 +484,7 @@ describe('abort during tool execution ends the turn', () => {
description: '',
parameters: {},
async execute() {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
agent.cancel({ kind: 'user' })
return [{ type: 'text', text: 'done' }]
},
}))
@@ -517,7 +523,7 @@ describe('steering from late extension points is never stranded', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let steeredOnce = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, _signal, next) => {
if (!steeredOnce) {
steeredOnce = true
agent.steer([{ type: 'text', text: 'one more thing' }])
@@ -591,26 +597,6 @@ describe('steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
})
it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
const adapter = new MockAdapter(['hang', textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.steer([{ type: 'text', text: 'redirect' }])
// Abort ONLY the in-flight step, via its AbortController directly — NOT
// cancel(), which clears the inbox and would drop the queued steering this
// test proves survives a step abort. There is no public step-only abort
// verb (cancel() is the only public stop primitive), so reach the private
// controller the loop registered.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
await waitForIdle(ctx, agent)
// a new turn ran with the steering content delivered as a message
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('redirect')
})
})
describe('plugin exceptions are contained', () => {
@@ -767,7 +753,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal) => {
return { ...config, provider: 'mock', model: 'mock' }
})
@@ -1481,7 +1467,7 @@ describe('surface: assistant/message records exact empty provenance when no chun
await mountInvariants(ctx)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal) => ({
role: 'assistant' as const,
content: [{ type: 'text' as const, text: 'injected' }],
}))
@@ -1584,7 +1570,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 50))
agent.cancel('user cancelled during assembly')
agent.cancel({ kind: 'user' })
releaseAssemble()
await waitForIdle(ctx, agent)
@@ -1596,15 +1582,12 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
kind: 'aborted',
reason: 'user cancelled during assembly',
})
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled during assembly' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => {
@@ -1689,7 +1672,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.cancel('user cancelled')
agent.cancel({ kind: 'user' })
releasePreStep()
await waitForIdle(ctx, agent)
@@ -1700,10 +1683,10 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: 'user cancelled' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user cancelled' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {

View File

@@ -170,7 +170,7 @@ describe('toError normalization', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, _next) => {
if (!threwOnce) {
threwOnce = true
throw { code: 500 } // non-Error throw, goes through runStep catch
@@ -200,7 +200,7 @@ describe('coded error data emission', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, next) => {
if (!threwOnce) {
threwOnce = true
throw new LlmError('server overloaded', 'RATE_LIMIT')

View File

@@ -56,7 +56,7 @@ describe('agent/prompt-submit', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const seen: string[] = []
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next) => {
seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join(''))
return next()
})
@@ -182,7 +182,7 @@ describe('agent/prompt-submit', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
})
@@ -497,7 +497,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let forced = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next): Promise<ContinuationDecision> => {
if (!forced) {
forced = true
return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } }
@@ -662,7 +662,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
)
})
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
return next()

View File

@@ -236,7 +236,7 @@ describe('agent loop', () => {
assembly.variables['model'] = 'mock'
return next()
})
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
return { ...config, provider: 'mock', model: 'mock' }
})
const agent = ctx.agentLoop.create(SessionId('a-late-model'), {})
@@ -538,7 +538,7 @@ describe('agent loop', () => {
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => {
if (steps < 3) return { action: 'continue' as const }
return next()
})
@@ -577,7 +577,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
// The seed is frozen — config is not a mutable per-call knob; a switch
// is proposed by returning a replacement, and the loop logs it.
expect(Object.isFrozen(config)).toBe(true)
@@ -703,10 +703,10 @@ describe('agent loop', () => {
// wait until the stream is hanging, then cancel
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
agent.cancel('user interrupt')
agent.cancel({ kind: 'user' })
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
expect(reasons).toEqual([{ kind: 'aborted' }])
})
it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => {
@@ -743,7 +743,7 @@ describe('agent loop', () => {
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
// Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, _signal, next) => {
if (steps < 2) return { action: 'continue' as const }
return next()
})
@@ -895,7 +895,7 @@ describe('agent loop', () => {
]])
const ctx = await harness(adapter)
let stepResults = 0
ctx.on('agent/step-result', async (_agent, _turn, _step, message, next) => {
ctx.on('agent/step-result', async (_agent, _turn, _step, message, _signal, next) => {
stepResults += 1
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
return next()

View File

@@ -167,7 +167,7 @@ describe('request stability across the loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let injected = false
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
if (!injected) {
injected = true
agent.inject([{ type: 'text', text: '[late context]' }], { source: { kind: 'plugin', plugin: 'test' } })
@@ -243,7 +243,7 @@ describe('request stability across the loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => {
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
const config = await next()
// next() resolves the SAME frozen seed — in-place shaping after
// delegation is unrepresentable, so a "mutate what next() returned"
@@ -280,7 +280,7 @@ describe('request stability across the loop', () => {
send(agent, 'go')
await waitForIdle(ctx, agent)
ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' })
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: ['<END>'] }))
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => ({ ...config, temperature: 0.5, maxTokens: 99, stop: ['<END>'] }))
send(agent, 'again')
await waitForIdle(ctx, agent)

View File

@@ -204,7 +204,7 @@ describe('agent post-step and request-error lifecycle', () => {
send(agent)
const idle = waitForIdle(ctx, agent)
await postStepEntered
agent.cancel('cancelled during max-tokens post-step')
agent.cancel({ kind: 'user' })
await idle
expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({
@@ -212,7 +212,7 @@ describe('agent post-step and request-error lifecycle', () => {
})
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted', reason: 'cancelled during max-tokens post-step' } },
data: { reason: { kind: 'aborted' } },
})
})
@@ -587,7 +587,7 @@ describe('agent post-step and request-error lifecycle', () => {
const idle = waitForIdle(ctx, agent)
await recoveryEntered
if (action === 'cancel') {
agent.cancel('cancelled during recovery')
agent.cancel({ kind: 'user' })
await idle
} else {
await ctx.fiber.dispose()
@@ -596,7 +596,7 @@ describe('agent post-step and request-error lifecycle', () => {
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: action === 'cancel' ? { kind: 'aborted', reason: 'cancelled during recovery' } : { kind: 'disposed' } },
data: { reason: action === 'cancel' ? { kind: 'aborted' } : { kind: 'disposed' } },
})
})
})

View File

@@ -193,7 +193,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
order.push('agent/created')
})
ctx.on('agent/session-start', (agent) => {
expect(() => { agent.cancel('now live') }).not.toThrow()
expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow()
order.push('agent/session-start')
})

View File

@@ -9,7 +9,7 @@ import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -461,7 +461,7 @@ describe('tool-call scheduler: abort handling', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'assistant/message') {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('already aborted')
agent.cancel({ kind: 'user' })
}
})
@@ -476,20 +476,12 @@ describe('tool-call scheduler: abort handling', () => {
isError: e.data.isError,
error: e.data.error,
}))).toEqual([
{
callId: CallId('c1'),
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
},
{
callId: CallId('c2'),
isError: true,
error: { name: 'AbortError', code: 'ABORTED' },
},
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
])
})
it('stops starting siblings when abort fires during ordered pre-execute', async () => {
it('skips dispatch and stops starting siblings when abort fires during ordered pre-execute', async () => {
const adapter = new MockAdapter([
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
textResponse('should never be requested'),
@@ -500,24 +492,25 @@ describe('tool-call scheduler: abort handling', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.callId === CallId('c1')) {
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('pre cancelled')
agent.cancel({ kind: 'user' })
}
return next()
})
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 1)
await new Promise(r => setTimeout(r, 5))
expect(gated.started).toEqual(['1'])
gated.release('1')
await waitForIdle(ctx, agent)
expect(gated.started).toEqual([])
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
.toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({
callId: e.data.callId,
isError: e.data.isError,
error: e.data.error,
}))).toEqual([
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
])
})
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
@@ -536,7 +529,7 @@ describe('tool-call scheduler: abort handling', () => {
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop now')
agent.cancel({ kind: 'user' })
gated.release('1')
gated.release('2')
await waitForIdle(ctx, agent)
@@ -552,8 +545,8 @@ describe('tool-call scheduler: abort handling', () => {
errorInfo: e.data.error,
})))
.toEqual([
{ callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: 'ABORTED' } },
{ callId: CallId('c3'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
{ callId: CallId('c4'), isError: true, errorInfo: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
])
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
expect(settled.map(e => e.type))
@@ -586,7 +579,7 @@ describe('tool-call scheduler: abort handling', () => {
agent.send([{ type: 'text', text: 'go' }])
await until(() => gated.started.length === 2)
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop before barrier')
agent.cancel({ kind: 'user' })
gated.release('1')
gated.release('2')
await waitForIdle(ctx, agent)
@@ -595,6 +588,6 @@ describe('tool-call scheduler: abort handling', () => {
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
.toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } })
})
})

View File

@@ -60,7 +60,7 @@ describe('agent/turn-stop', () => {
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
let steered = false
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
ctx.on('agent/turn-continuation', async (subject, _turn, _default, _signal, next) => {
const downstream = await next()
if (subject === agent && !steered) {
steered = true