Merge remote-tracking branch 'origin/worktree/context-source-cards' into worktree/context-forms-remaining
# Conflicts: # apps/web/tests/snapshots/queue-actions/layout.expected.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/goal.i18n.yaml # docs/core-data-structures/goal.md # docs/core-data-structures/goal.zh.md # docs/event-producer-consumer.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/missing-sandbox-runner/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/partial-landlock-child-failure/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.jsonl # examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-report/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # packages/bash/tool-bash/tests/integration.spec.ts # packages/context/time-context/src/index.ts # packages/context/tmux-context/src/index.ts # packages/core/agent-loop/src/agent.ts # packages/core/system-prompt/src/index.ts # packages/goal/goal/src/domain.ts # packages/goal/goal/src/index.ts # packages/goal/goal/src/render.ts # packages/plan/plan-mode/src/index.ts
This commit is contained in:
@@ -153,7 +153,7 @@ describe('AgentLoop initiator scope', () => {
|
||||
const { ctx } = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' })
|
||||
let signals: AbortSignal[] = []
|
||||
let admissionSignals: AbortSignal[] = []
|
||||
let preStepSignals: 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)
|
||||
@@ -164,16 +164,13 @@ describe('AgentLoop initiator scope', () => {
|
||||
if (context.agent === agent) capture(context.signal)
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => {
|
||||
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => {
|
||||
if (subject === agent) {
|
||||
expect(ctx.agents.requireInitiator()).toBe(agent)
|
||||
admissionSignals.push(signal)
|
||||
preStepSignals.push(signal)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/step', (subject, _turn, _step, signal) => {
|
||||
if (subject === agent) capture(signal)
|
||||
})
|
||||
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
|
||||
if (subject === agent) capture(signal)
|
||||
return next()
|
||||
@@ -197,19 +194,19 @@ describe('AgentLoop initiator scope', () => {
|
||||
const firstSignal = signals[0]
|
||||
expect(firstSignal).toBeDefined()
|
||||
expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
|
||||
expect(admissionSignals).toHaveLength(1)
|
||||
expect(admissionSignals[0]).not.toBe(firstSignal)
|
||||
expect(preStepSignals).toHaveLength(2)
|
||||
expect(new Set(preStepSignals)).toEqual(new Set([firstSignal]))
|
||||
|
||||
signals = []
|
||||
admissionSignals = []
|
||||
preStepSignals = []
|
||||
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(admissionSignals).toHaveLength(1)
|
||||
expect(admissionSignals[0]).not.toBe(secondSignal)
|
||||
expect(preStepSignals).toHaveLength(1)
|
||||
expect(preStepSignals[0]).toBe(secondSignal)
|
||||
expect(secondSignal).not.toBe(firstSignal)
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -26,51 +26,17 @@ function send(agent: Agent, text: string): void {
|
||||
}
|
||||
|
||||
describe('Agent', () => {
|
||||
it('does not echo caller-owned message identities from delivery methods', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('one'),
|
||||
textResponse('two'),
|
||||
textResponse('three'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const message = (text: string) => createUserMessage({
|
||||
content: [{ type: 'text' as const, text }],
|
||||
source: { kind: 'user' as const },
|
||||
})
|
||||
const call = (method: 'send' | 'inject' | 'followup' | 'steer', args: unknown[]): unknown => {
|
||||
const implementation: unknown = Reflect.get(agent, method)
|
||||
if (typeof implementation !== 'function') throw new Error(`missing Agent.${method}`)
|
||||
return Reflect.apply(implementation, agent, args)
|
||||
}
|
||||
|
||||
expect(call('send', [message('quiet'), {
|
||||
target: 'next-turn',
|
||||
wakeup: false,
|
||||
}])).toBeUndefined()
|
||||
expect(call('inject', [message('context')])).toBeUndefined()
|
||||
expect(call('followup', [message('followup')])).toBeUndefined()
|
||||
const receipt = agent.steer(message('steering'))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 3, step: 1 })
|
||||
})
|
||||
|
||||
it('idle inject() appends context without opening a turn or requesting a flush', async () => {
|
||||
it('idle inject() durably stages context without opening a turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'p' } }))
|
||||
|
||||
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
|
||||
expect(agent.session.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
await agent.whenIdle()
|
||||
expect(flushes).toBe(0)
|
||||
})
|
||||
|
||||
it('inject() preserves an explicitly empty plugin source', async () => {
|
||||
@@ -80,11 +46,49 @@ describe('Agent', () => {
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'empty plugin source' }], source: { kind: 'plugin', plugin: '' } }))
|
||||
|
||||
const injected = agent.session.events.at(-1)
|
||||
expect(injected?.type === 'user/message' && injected.data.source)
|
||||
expect(injected?.type === 'agent/inbox/spliced' && injected.data.inserted[0]?.source)
|
||||
.toEqual({ kind: 'plugin', plugin: '' })
|
||||
})
|
||||
|
||||
it('idle inject() rejects invalid input before append', async () => {
|
||||
it('emits exact inserted, claimed, and discarded inbox messages', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('ok')]))
|
||||
const agent = ctx.agentLoop.create(SessionId('inbox-events'), { provider: 'mock', model: 'mock' })
|
||||
const inserted: unknown[] = []
|
||||
const claimed: unknown[] = []
|
||||
const discarded: unknown[] = []
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start')
|
||||
})
|
||||
ctx.on('agent/inbox/inserted', (subject, event) => {
|
||||
if (subject === agent) inserted.push(event)
|
||||
})
|
||||
ctx.on('agent/inbox/claimed', (subject, event) => {
|
||||
if (subject === agent) {
|
||||
lifecycle.push('agent/inbox/claimed')
|
||||
claimed.push(event)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/inbox/discarded', (subject, event) => {
|
||||
if (subject === agent) discarded.push(event)
|
||||
})
|
||||
const context = createUserMessage({
|
||||
content: [{ type: 'text', text: 'discard me' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
agent.inject(context)
|
||||
agent.inbox.remove(context.id)
|
||||
const prompt = createUserMessage({ content: [{ type: 'text', text: 'run' }], source: { kind: 'user' } })
|
||||
agent.followup(prompt)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(inserted).toEqual([{ message: context }, { message: prompt }])
|
||||
expect(discarded).toEqual([{ message: context }])
|
||||
expect(claimed).toEqual([{ message: prompt, turn: 1 }])
|
||||
expect(lifecycle).toEqual(['turn/start', 'agent/inbox/claimed'])
|
||||
})
|
||||
|
||||
it('idle inject() rejects invalid input before enqueue', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('ok')]))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
@@ -120,79 +124,6 @@ describe('Agent', () => {
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
})
|
||||
|
||||
it('awaits the turn-end checkpoint before claiming the next queued turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const flushedTurns: number[] = []
|
||||
ctx.on('session/flush', async (session) => {
|
||||
const turnEnd = session.events.findLast(event => event.type === 'turn/end')
|
||||
flushedTurns.push(turnEnd?.data.turn ?? 0)
|
||||
if (turnEnd?.data.turn === 1) await firstFlush.promise
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
send(agent, 'second')
|
||||
|
||||
await vi.waitFor(() => { expect(flushedTurns).toEqual([1]) })
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
firstFlush.resolve(undefined)
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(flushedTurns).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('keeps whenIdle pending through the final turn checkpoint', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('done')]))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const flush = Promise.withResolvers<undefined>()
|
||||
let flushStarted = false
|
||||
ctx.on('session/flush', () => {
|
||||
flushStarted = true
|
||||
return flush.promise
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await vi.waitFor(() => { expect(flushStarted).toBe(true) })
|
||||
let idleSettled = false
|
||||
const idle = agent.whenIdle().then(() => { idleSettled = true })
|
||||
await Promise.resolve()
|
||||
expect(idleSettled).toBe(false)
|
||||
|
||||
flush.resolve(undefined)
|
||||
await idle
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('reports a rejected turn-end checkpoint and continues queued work', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const failure = new Error('disk unavailable')
|
||||
const errors: { turn: number; step: number; error: unknown }[] = []
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => {
|
||||
flushes += 1
|
||||
if (flushes === 1) throw failure
|
||||
})
|
||||
ctx.on('agent/error', (subject, turn, step, error) => {
|
||||
if (subject === agent) errors.push({ turn, step, error })
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
send(agent, 'second')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(flushes).toBe(2)
|
||||
expect(errors).toEqual([{ turn: 1, step: 1, error: failure }])
|
||||
expect(warning).toHaveBeenCalledWith(expect.stringContaining('session/flush failed at turn 1: disk unavailable'))
|
||||
warning.mockRestore()
|
||||
})
|
||||
|
||||
it('whenIdle() resolves immediately without active work', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('ok')]))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
/**
|
||||
* Tests for the queue-aware `Agent.cancel()` primitive. The default clears
|
||||
* queued and steering work, while `keepInbox` preserves pending input and
|
||||
* resumes waking turns after the active turn reaches quiescence. The suite
|
||||
* queued and steering work, while `keepInbox` preserves pending input for a
|
||||
* later wake after the active turn reaches quiescence. The suite
|
||||
* covers every landing window plus signal reset and `whenIdle()` quiescence.
|
||||
* @module dsh-agent-loop/tests/cancel
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
@@ -55,33 +55,6 @@ function userTexts(agent: Agent): string[] {
|
||||
}
|
||||
|
||||
describe('Agent.cancel()', () => {
|
||||
it('notifies every observer before clearing work and contains listener failures', async () => {
|
||||
const adapter = new MockAdapter([textResponse('must remain unused')])
|
||||
const ctx = await harness(adapter)
|
||||
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, cause) => {
|
||||
if (subject !== agent) return
|
||||
seen.push(`first:${cause.kind}`)
|
||||
subject.followup(createUserMessage({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } }))
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => {
|
||||
if (subject === agent) seen.push(`second:${cause.kind}`)
|
||||
})
|
||||
|
||||
send(agent, 'drop me')
|
||||
agent.cancel({ kind: 'user' })
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
agent.cancel({ kind: 'parent' })
|
||||
|
||||
expect(seen).toEqual(['first:user', 'second:user'])
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
|
||||
})
|
||||
|
||||
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -99,77 +72,76 @@ describe('Agent.cancel()', () => {
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
|
||||
})
|
||||
|
||||
it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
it('cancel({ keepInbox: true }) does not restore work already claimed by a waking send', async () => {
|
||||
const adapter = new MockAdapter([textResponse('wake reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const discards: unknown[] = []
|
||||
ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
|
||||
const cancelRequests: unknown[] = []
|
||||
ctx.on('agent/cancel-requested', (subject, cause) => { if (subject === agent) cancelRequests.push(cause) })
|
||||
|
||||
// Queue a turn WITHOUT waking the driver, so it sits in the inbox.
|
||||
agent.send(createUserMessage({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
|
||||
// keepInbox cancel: no active turn, work preserved, no discard event. With
|
||||
// nothing to abort and nothing discarded, the call is a documented no-op,
|
||||
// so it emits no cancel-requested either.
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'preserved' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
// A waking send starts and claims synchronously, so keepInbox has no
|
||||
// pending item to preserve by the time this cancellation runs.
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
expect(discards).toEqual([])
|
||||
expect(cancelRequests).toEqual([])
|
||||
|
||||
// The preserved item still runs once the driver is woken by a later send.
|
||||
send(agent, 'wake it')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
|
||||
})
|
||||
|
||||
it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A quiet item alone must NOT wake the driver: no turn runs and whenIdle
|
||||
// resolves (the agent is quiescent), leaving the item queued.
|
||||
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
|
||||
expect(agent.session.events.some(event =>
|
||||
event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled')).toBe(false)
|
||||
await agent.whenIdle()
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
expect(agent.inbox.nextTurn).toHaveLength(0)
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
|
||||
// A later waking send drives the loop, and the quiet item rides along first.
|
||||
send(agent, 'wake')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(userTexts(agent)).toEqual(['quiet', 'wake'])
|
||||
})
|
||||
|
||||
it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => {
|
||||
const adapter = new MockAdapter([textResponse('reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send(createUserMessage({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }), { target: 'next-turn', wakeup: false })
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel({ kind: 'user' })
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'wake it')
|
||||
await idle
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
expect(userTexts(agent)).toEqual(['wake it'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
|
||||
it('cancel({ keepInbox: true }) parks queued work after an active turn aborts', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
'hang',
|
||||
textResponse('preserved reply'),
|
||||
textResponse('wake reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('keep-after-abort'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'active')
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
send(agent, 'preserved')
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(userTexts(agent)).toEqual(['active'])
|
||||
expect(agent.inbox.nextTurn).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'wake it')
|
||||
await idle
|
||||
expect(userTexts(agent)).toEqual(['active', 'preserved', 'wake it'])
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('cancel after waking send closes its synchronously opened turn without a step', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// send() queues synchronously (status still idle, loop microtask not yet
|
||||
// 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({ kind: 'user' })
|
||||
|
||||
// Give the loop a chance to wake and process the cancel.
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
// No turn was opened — the queued prompt was dropped, never recorded.
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(0)
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
@@ -247,7 +219,7 @@ describe('Agent.cancel()', () => {
|
||||
await expect(Promise.race([
|
||||
replacementObservation,
|
||||
new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)),
|
||||
])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 })
|
||||
])).resolves.toEqual({ status: 'idle', requests: 1, turns: 2 })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'later')
|
||||
@@ -256,8 +228,12 @@ describe('Agent.cancel()', () => {
|
||||
expect(userTexts(agent)).toEqual(['first', 'later'])
|
||||
})
|
||||
|
||||
it('replacement work queued after idle-listener cancellation still runs', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
|
||||
it('replacement work queued after idle-listener cancellation waits for another wakeup', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('first reply'),
|
||||
textResponse('replacement reply'),
|
||||
textResponse('wake reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
@@ -277,8 +253,15 @@ describe('Agent.cancel()', () => {
|
||||
if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work')
|
||||
await replacementIdle
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(userTexts(agent)).toEqual(['first', 'surviving replacement'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(userTexts(agent)).toEqual(['first'])
|
||||
expect(agent.inbox.nextTurn).toHaveLength(1)
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'wake it')
|
||||
await idle
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(userTexts(agent)).toEqual(['first', 'surviving replacement', 'wake it'])
|
||||
})
|
||||
|
||||
it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => {
|
||||
@@ -296,47 +279,12 @@ describe('Agent.cancel()', () => {
|
||||
agent.cancel({ kind: 'user' })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
expect(userTexts(agent)).toEqual(['go'])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cancel({ keepInbox: true }) aborts the active turn and drains the queued tail in FIFO order', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
'hang',
|
||||
textResponse('second reply'),
|
||||
textResponse('third reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('keep-inbox-running'), { provider: 'mock', model: 'mock' })
|
||||
const reasons: TurnEndReason[] = []
|
||||
const discards: unknown[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discards.push(items)
|
||||
})
|
||||
|
||||
send(agent, 'active')
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
send(agent, 'queued second')
|
||||
send(agent, 'queued third')
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
await idle
|
||||
|
||||
expect(discards).toEqual([])
|
||||
expect(userTexts(agent)).toEqual(['active', 'queued second', 'queued third'])
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'aborted' },
|
||||
{ kind: 'completed' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('cancel from an assistant/message observer skips execution but balances replay', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'danger', {}),
|
||||
@@ -368,7 +316,7 @@ describe('Agent.cancel()', () => {
|
||||
dispose()
|
||||
|
||||
expect(executions).toBe(0)
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
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')
|
||||
@@ -387,7 +335,7 @@ describe('Agent.cancel()', () => {
|
||||
.find(block => block.type === 'tool-result')
|
||||
expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'aborted' },
|
||||
{ kind: 'aborted', reason: { kind: 'user' } },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
})
|
||||
@@ -414,33 +362,6 @@ describe('Agent.cancel()', () => {
|
||||
expect(reasons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A turn/start listener fires before a step controller exists, so the
|
||||
// turn-scoped marker—not step abort—must drop the pending step.
|
||||
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({ kind: 'user' })
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
dispose()
|
||||
|
||||
// No step streamed (the model never ran), and the turn ended aborted with
|
||||
// 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' }])
|
||||
})
|
||||
|
||||
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -466,12 +387,12 @@ describe('Agent.cancel()', () => {
|
||||
// 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' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
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)
|
||||
})
|
||||
|
||||
it('disposal from a synchronous step/start session-event listener closes the open step as disposed', async () => {
|
||||
it('disposal from a synchronous step/start session-event listener stops before adapter dispatch', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not stream')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -501,8 +422,7 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
expect(streamed).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(false)
|
||||
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)
|
||||
})
|
||||
@@ -533,7 +453,7 @@ describe('Agent.cancel()', () => {
|
||||
// Only ONE step ran (the second was cancelled in the stopping window),
|
||||
// and the shared turn signal classified the durable outcome as aborted.
|
||||
expect(steps).toBe(1)
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
})
|
||||
|
||||
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
|
||||
@@ -541,8 +461,8 @@ describe('Agent.cancel()', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// `agent/status` is synchronous, so cancellation can land after the first
|
||||
// pre-step check; the second check must drop the now-empty turn.
|
||||
// `agent/status` is synchronous, so cancellation can land before the
|
||||
// durable turn-start commit and must drop the reserved work.
|
||||
let streamed = false
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
@@ -559,8 +479,7 @@ describe('Agent.cancel()', () => {
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => {
|
||||
// Cancellation must not settle idle while replacement work remains queued.
|
||||
it('a running-listener cancellation parks replacement work until another wakeup', async () => {
|
||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -578,32 +497,35 @@ describe('Agent.cancel()', () => {
|
||||
await idle
|
||||
dispose()
|
||||
|
||||
// whenIdle() resolved only AFTER B's turn ran: B's user message + a turn/end
|
||||
// are in the log, and A was dropped.
|
||||
expect(userTexts(agent)).toContain('B')
|
||||
expect(userTexts(agent)).not.toContain('A')
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(agent.inbox.nextTurn).toHaveLength(1)
|
||||
|
||||
const replacementIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'C')
|
||||
await replacementIdle
|
||||
expect(userTexts(agent)).toEqual(['B', 'C'])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => {
|
||||
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel() clears A;
|
||||
// prompt B is queued before the loop resumes from the idle wait.
|
||||
it('a prompt queued during pre-step cancellation waits for another wakeup', async () => {
|
||||
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
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({ kind: 'user' }) // arms marker, clears A
|
||||
send(agent, 'B') // B races in before the loop resumes
|
||||
send(agent, 'A')
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel({ kind: 'user' })
|
||||
send(agent, 'B')
|
||||
|
||||
// whenIdle() must resolve only after B's turn fully ran — by which point B's user message
|
||||
// and a turn/end are in the log.
|
||||
await idle
|
||||
expect(userTexts(agent)).toContain('B')
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
|
||||
// A was dropped (never ran); only B's turn is recorded.
|
||||
expect(userTexts(agent)).not.toContain('A')
|
||||
expect(userTexts(agent)).toEqual([])
|
||||
expect(agent.inbox.nextTurn).toHaveLength(1)
|
||||
|
||||
const replacementIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'C')
|
||||
await replacementIdle
|
||||
expect(userTexts(agent)).toEqual(['B', 'C'])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
|
||||
@@ -628,14 +550,18 @@ describe('Agent.cancel()', () => {
|
||||
expect(turnStarts.length).toBe(1) // only the original (cancelled) turn
|
||||
// The steering text was dropped — it never reached the log.
|
||||
const flat = agent.session.events
|
||||
.filter(e => e.type === 'steering/message')
|
||||
.flatMap(e => e.type === 'steering/message' ? e.data.message.content : [])
|
||||
.filter(e => e.type === 'user/message')
|
||||
.flatMap(e => e.data.content)
|
||||
.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')])
|
||||
it('parks replacement work queued synchronously by an abort observer', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
'hang',
|
||||
textResponse('replacement reply'),
|
||||
textResponse('wake reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('abort-observer-replacement'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
@@ -644,7 +570,7 @@ describe('Agent.cancel()', () => {
|
||||
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)
|
||||
const idle = agent.whenIdle()
|
||||
agent.cancel({ kind: 'user' })
|
||||
await Promise.race([
|
||||
idle,
|
||||
@@ -660,15 +586,22 @@ describe('Agent.cancel()', () => {
|
||||
}),
|
||||
])
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(userTexts(agent)).toEqual(['original', 'replacement'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(userTexts(agent)).toEqual(['original'])
|
||||
expect(agent.inbox.nextTurn).toHaveLength(1)
|
||||
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' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
|
||||
const replacementIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'wake it')
|
||||
await replacementIdle
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(userTexts(agent)).toEqual(['original', 'replacement', 'wake it'])
|
||||
})
|
||||
|
||||
it('keeps the first typed cause for an active turn and detaches the runtime reason', async () => {
|
||||
it('keeps the first typed cause for an active turn', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' })
|
||||
@@ -677,16 +610,17 @@ describe('Agent.cancel()', () => {
|
||||
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)
|
||||
expect(runtimeReason).toBe(supplied)
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({
|
||||
kind: 'aborted',
|
||||
reason: { kind: 'parent' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the first user cancellation when lifecycle teardown races it', async () => {
|
||||
@@ -704,13 +638,12 @@ describe('Agent.cancel()', () => {
|
||||
await handle.dispose()
|
||||
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'prompt-submit',
|
||||
'pre-step',
|
||||
'system-prompt',
|
||||
'step',
|
||||
'request',
|
||||
'stopping',
|
||||
'tool',
|
||||
@@ -730,8 +663,8 @@ describe('Agent.cancel()', () => {
|
||||
}
|
||||
|
||||
switch (stage) {
|
||||
case 'prompt-submit':
|
||||
ctx.on('agent/prompt-submit', async (subject, _message, signal, next) => {
|
||||
case 'pre-step':
|
||||
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
return next()
|
||||
})
|
||||
@@ -745,11 +678,6 @@ describe('Agent.cancel()', () => {
|
||||
return next()
|
||||
})
|
||||
break
|
||||
case 'step':
|
||||
ctx.on('agent/step', async (subject, _turn, _step, signal) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
})
|
||||
break
|
||||
case 'request':
|
||||
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
|
||||
if (subject === agent) await blockUntilAbort(signal)
|
||||
@@ -781,11 +709,8 @@ describe('Agent.cancel()', () => {
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
if (stage === 'prompt-submit') {
|
||||
expect(turnEnd).toBeUndefined()
|
||||
} else {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
}
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -146,8 +146,9 @@ describe('config-driven session id', () => {
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('saved')]))
|
||||
const sessionId = SessionId('config-exact-overlap')
|
||||
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
|
||||
const config = { agents: [{ id: 'main', sessionId, provider: 'mock', model: 'mock' }] }
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
const first = ctx.agents.get(sessionId) as Agent
|
||||
@@ -158,7 +159,9 @@ describe('config-driven session id', () => {
|
||||
cleanupStarted.resolve(undefined)
|
||||
await cleanupGate.promise
|
||||
})
|
||||
first.inject(createUserMessage({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
const idle = waitForIdle(ctx, first)
|
||||
first.followup(createUserMessage({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'user' } }))
|
||||
await idle
|
||||
await ctx.sessions.flush(first.session)
|
||||
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
|
||||
.toContain('persist before replacement')
|
||||
@@ -293,14 +296,15 @@ describe('config-driven session id', () => {
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)(
|
||||
'abandons an exact-id persistence lookup that later %s when AgentLoop disposal starts',
|
||||
'abandons an exact-id preparation that later %s when AgentLoop disposal starts',
|
||||
async (outcome) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const loading = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.load>>>()
|
||||
vi.spyOn(ctx.sessionPersistence, 'load').mockReturnValue(loading.promise)
|
||||
const preparing = Promise.withResolvers<SessionPreparation>()
|
||||
vi.spyOn(ctx.sessionPersistence, 'prepare').mockReturnValue(preparing.promise)
|
||||
const released = vi.fn()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const failures: unknown[] = []
|
||||
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
|
||||
@@ -310,18 +314,15 @@ describe('config-driven session id', () => {
|
||||
})
|
||||
await loop.dispose()
|
||||
if (outcome === 'resolve') {
|
||||
loading.resolve({
|
||||
meta: {
|
||||
id: SessionId('config-exact-dispose'),
|
||||
version: 0,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
events: [],
|
||||
})
|
||||
preparing.resolve(SessionPreparation.create(
|
||||
ctx.sessions.prepare(SessionId('config-exact-dispose')),
|
||||
{ release: released },
|
||||
))
|
||||
} else {
|
||||
loading.reject(new Error('startup cancelled by teardown'))
|
||||
preparing.reject(new Error('startup cancelled by teardown'))
|
||||
}
|
||||
await Promise.resolve()
|
||||
if (outcome === 'resolve') await expect.poll(() => released).toHaveBeenCalledOnce()
|
||||
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
|
||||
expect(failures).toEqual([])
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, CallId, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import LlmService, { createUserMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { ReactLoopAgent } from '../src/agent.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
@@ -53,211 +53,12 @@ function send(agent: Agent, text: string) {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }))
|
||||
}
|
||||
|
||||
function inboxText(item: InboxItem): string {
|
||||
return item.message.content
|
||||
function inboxText(message: UserMessage): string {
|
||||
return message.content
|
||||
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('addressable inbox operations', () => {
|
||||
it('edits in place and removes exactly one queued item', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('first reply'),
|
||||
textResponse('edited reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
|
||||
const admission = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('agent/prompt-submit', async (_subject, message, _signal, next) => {
|
||||
if (message.content[0]?.type === 'text' && message.content[0].text === 'first') {
|
||||
admission.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
const pending: InboxItem[] = []
|
||||
const updates: { id: string; text: string }[] = []
|
||||
const discards: string[][] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent && inboxText(item) !== 'first') pending.push(item)
|
||||
})
|
||||
ctx.on('agent/inbox/update', (subject, item) => {
|
||||
if (subject === agent) updates.push({ id: item.id, text: inboxText(item) })
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discards.push(items.map(item => item.id))
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await admission.promise
|
||||
send(agent, 'remove me')
|
||||
send(agent, 'edit me')
|
||||
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
|
||||
|
||||
const remove = pending[0]!
|
||||
const edit = pending[1]!
|
||||
expect(agent.updateInbox(edit.id, {
|
||||
kind: 'edit',
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})).toBe('applied')
|
||||
expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied')
|
||||
expect(updates).toEqual([{ id: edit.id, text: 'edited' }])
|
||||
expect(discards).toEqual([[remove.id]])
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
release.resolve(undefined)
|
||||
await idle
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.type === 'user/message'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
: ''))
|
||||
.toEqual(['first', 'edited'])
|
||||
expect(agent.updateInbox(edit.id, { kind: 'remove' })).toBe('not-found')
|
||||
})
|
||||
|
||||
it('does not mutate steering occurrences', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const pending: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent && item.placement === 'steering') pending.push(item)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'admitted prompt')
|
||||
await entered.promise
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'keep me' }], source: { kind: 'user' } }))
|
||||
expect(pending.map(inboxText)).toEqual(['keep me'])
|
||||
|
||||
const steering = pending[0]!
|
||||
expect(agent.updateInbox(steering.id, {
|
||||
kind: 'edit',
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})).toBe('not-found')
|
||||
expect(agent.updateInbox(steering.id, { kind: 'remove' })).toBe('not-found')
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'steering/message')
|
||||
.map(event => event.type === 'steering/message'
|
||||
? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
: ''))
|
||||
.toEqual(['keep me'])
|
||||
})
|
||||
|
||||
it('strictly transfers a queued occurrence into the open turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent) enqueued.push(item)
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discarded.push(...items)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'open the turn')
|
||||
const receipt = agent.steer(createUserMessage({
|
||||
content: [{ type: 'text', text: 'steer this message' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await entered.promise
|
||||
const queued = enqueued.find(item => inboxText(item) === 'steer this message')!
|
||||
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
||||
const steering = enqueued.find(item => item.placement === 'steering')!
|
||||
expect(steering.id).not.toBe(queued.id)
|
||||
expect(steering.message).toBe(queued.message)
|
||||
expect(discarded).toEqual([queued])
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events.flatMap(event =>
|
||||
event.type === 'steering/message' ? [event.data.message] : [],
|
||||
)).toEqual([queued.message])
|
||||
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 1, step: 1 })
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found')
|
||||
})
|
||||
|
||||
it('keeps a queued occurrence when the next-step window is closed', () => {
|
||||
const ctx = new Context()
|
||||
const session = Session.create(SessionId('queue-to-steer-closed'))
|
||||
const agent = new ReactLoopAgent(ctx, session.id, {}, session)
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) })
|
||||
ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) })
|
||||
|
||||
agent.send(
|
||||
createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }),
|
||||
{ target: 'next-turn', wakeup: false },
|
||||
)
|
||||
const queued = enqueued[0]!
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable')
|
||||
expect(discarded).toEqual([])
|
||||
expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied')
|
||||
})
|
||||
|
||||
it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent) return
|
||||
enqueued.push(item)
|
||||
if (item.placement === 'steering') agent.cancel({ kind: 'user' })
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discarded.push(...items)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'open the turn')
|
||||
await entered.promise
|
||||
send(agent, 'cancel during conversion')
|
||||
const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')!
|
||||
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
||||
const steering = enqueued.find(item => item.placement === 'steering')!
|
||||
expect(discarded).toEqual([steering, queued])
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant replay provenance', () => {
|
||||
it('records adapter replay state with the assembled assistant content', async () => {
|
||||
const response = textResponse('unchanged')
|
||||
@@ -281,8 +82,11 @@ describe('assistant replay provenance', () => {
|
||||
})
|
||||
|
||||
describe('abort during tool execution ends the turn', () => {
|
||||
it('records context accepted before a tool-step abort in the same turn', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
|
||||
it('parks context finalized after a tool-step abort until another wakeup', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'aborter', {}),
|
||||
textResponse('after wake'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
@@ -306,14 +110,20 @@ describe('abort during tool execution ends the turn', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
expect(events
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'tool/result'
|
||||
|| (event.type === 'user/message' && event.data.source.kind === 'plugin')
|
||||
|| event.type === 'step/end' || event.type === 'turn/end')
|
||||
.map(event => event.type))
|
||||
.toEqual(['tool/result', 'user/message', 'step/end', 'turn/end'])
|
||||
expect(events
|
||||
.toEqual(['tool/result', 'step/end', 'turn/end'])
|
||||
expect(agent.inbox.nextStep.map(inboxText))
|
||||
.toEqual(['accepted result context after abort'])
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'wake')
|
||||
await idle
|
||||
|
||||
expect(agent.session.events
|
||||
.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
|
||||
? [event.data.content]
|
||||
: []))
|
||||
@@ -377,7 +187,26 @@ describe('abort during tool execution ends the turn', () => {
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
it('records result context finalized after disposal cancellation', async () => {
|
||||
it('closes an empty admitted batch as a turn without a step', async () => {
|
||||
const adapter = new MockAdapter([textResponse('must not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
|
||||
if (subject !== agent) return next()
|
||||
return Promise.resolve({ kind: 'enter', messages: [] })
|
||||
})
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start'
|
||||
|| event.type === 'step/start' || event.type === 'turn/end').map(event => event.type))
|
||||
.toEqual(['turn/start', 'turn/end'])
|
||||
expect(agent.session.events.find(event => event.type === 'turn/end')?.data)
|
||||
.toEqual({ turn: 1, reason: { kind: 'completed' } })
|
||||
expect(agent.inbox.nextTurn).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('parks result context finalized after disposal cancellation without opening another turn', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
|
||||
const ctx = await harness(adapter)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
@@ -417,11 +246,13 @@ describe('abort during tool execution ends the turn', () => {
|
||||
.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
|
||||
? [event.data.content]
|
||||
: []))
|
||||
.toEqual([
|
||||
[{ type: 'text', text: 'accepted result context during disposal' }],
|
||||
])
|
||||
.toEqual([])
|
||||
expect(agent.inbox.nextStep.map(inboxText))
|
||||
.toEqual(['accepted result context during disposal'])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start'))
|
||||
.toHaveLength(1)
|
||||
expect(agent.session.events.find(event => event.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'disposed' })
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
|
||||
})
|
||||
|
||||
it('limits injection deferral to the current tool batch', async () => {
|
||||
@@ -457,10 +288,19 @@ describe('abort during tool execution ends the turn', () => {
|
||||
|
||||
send(agent, 'leave an unmatched historical call')
|
||||
await waitForIdle(ctx, agent)
|
||||
ctx.on('agent/step', (subject, turn) => {
|
||||
if (subject === agent && turn === 2) {
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => {
|
||||
const decision = await next()
|
||||
if (subject === agent && turn === 2 && decision.kind === 'enter') {
|
||||
disposeInjection()
|
||||
return {
|
||||
kind: 'enter' as const,
|
||||
messages: [...decision.messages, createUserMessage({
|
||||
content: [{ type: 'text', text: 'new turn context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
}
|
||||
}
|
||||
return decision
|
||||
})
|
||||
send(agent, 'start a text-only turn')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -499,65 +339,6 @@ describe('steering from late extension points is never stranded', () => {
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
|
||||
})
|
||||
|
||||
it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => {
|
||||
// Assert the same-turn shape; content alone cannot distinguish re-enqueue.
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('no tools, would stop'),
|
||||
textResponse('after goal reminder'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let steeredOnce = false
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
|
||||
steeredOnce = true
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'goal reminder from step/end' }], source: { kind: 'user' } }))
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(events.filter(e => e.type === 'step/start')).toHaveLength(2)
|
||||
// Same-turn steering precedes the second step.
|
||||
const steeringIdx = events.findIndex(e => e.type === 'steering/message')
|
||||
const step2Idx = events.map(e => e.type).lastIndexOf('step/start')
|
||||
expect(steeringIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(steeringIdx).toBeLessThan(step2Idx)
|
||||
// and it reached the next model request.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end')
|
||||
})
|
||||
|
||||
it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const turns: number[] = []
|
||||
let steeredOnce = false
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject !== agent.session) return
|
||||
if (event.type === 'turn/start') turns.push(event.data.turn)
|
||||
if (event.type === 'turn/end' && !steeredOnce) {
|
||||
steeredOnce = true
|
||||
expect(agent.acceptsNextStep).toBe(false)
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } }))
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
// the loop chains directly into turn 2 (status never returns to idle in
|
||||
// between), so the first idle transition means both turns are complete
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('plugin exceptions are contained', () => {
|
||||
@@ -574,14 +355,11 @@ describe('plugin exceptions are contained', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
||||
if (error instanceof Error) errors.push(error)
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['broken continuation plugin'])
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: { reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } },
|
||||
})
|
||||
|
||||
// the loop is still alive: a second send works normally
|
||||
send(agent, 'second')
|
||||
@@ -614,7 +392,7 @@ describe('disposal leaves the two-state status contract balanced', () => {
|
||||
await driverDone(agent)
|
||||
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(reasons).toEqual([{ kind: 'disposed' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
@@ -663,16 +441,15 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
||||
if (error instanceof Error) errors.push(error)
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.message).toContain('has no provider/model')
|
||||
expect(errors[0]!.message).toContain('agent/request')
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
||||
? turnEnd.data.reason.error.message
|
||||
: undefined).toContain('has no provider/model')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
||||
? turnEnd.data.reason.error.message
|
||||
: undefined).toContain('agent/request')
|
||||
})
|
||||
|
||||
it('the agent/request waterfall can supply the model for a model-less agent', async () => {
|
||||
@@ -690,7 +467,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
|
||||
})
|
||||
|
||||
it('agent/inbox/enqueue carries the exact message; steering/message records its source', async () => {
|
||||
it('durable inbox splices carry exact messages and the claimed steer preserves its source', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -704,30 +481,32 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
},
|
||||
}))
|
||||
|
||||
const queuedSources: MessageSource[] = []
|
||||
const queuedShapes: string[][] = []
|
||||
const placements: InboxPlacement[] = []
|
||||
ctx.on('agent/inbox/enqueue', (_agent, item) => {
|
||||
queuedSources.push(item.message.source)
|
||||
queuedShapes.push(Object.keys(item.message).sort())
|
||||
placements.push(item.placement)
|
||||
const insertedSources: MessageSource[] = []
|
||||
const insertedShapes: string[][] = []
|
||||
const targets: string[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'agent/inbox/spliced') return
|
||||
for (const message of event.data.inserted) {
|
||||
insertedSources.push(message.source)
|
||||
insertedShapes.push(Object.keys(message).sort())
|
||||
targets.push(event.data.target)
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(queuedSources).toEqual([
|
||||
expect(insertedSources).toEqual([
|
||||
{ kind: 'user' },
|
||||
{ kind: 'plugin', plugin: 'goal' },
|
||||
])
|
||||
expect(queuedShapes).toEqual([
|
||||
expect(insertedShapes).toEqual([
|
||||
['content', 'id', 'role', 'source'],
|
||||
['content', 'id', 'role', 'source'],
|
||||
])
|
||||
expect(placements).toEqual(['queued', 'steering'])
|
||||
// The drain appends the durable steering/message with the caller's source
|
||||
// intact — the log, not a transient emit, is where consumers read it.
|
||||
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.message.source] : [])
|
||||
expect(targets).toEqual(['next-turn', 'next-step'])
|
||||
const steeringSources = agent.session.events.flatMap(e =>
|
||||
e.type === 'user/message' && e.data.source.kind === 'plugin' ? [e.data.source] : [])
|
||||
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
|
||||
})
|
||||
|
||||
@@ -806,18 +585,24 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
|
||||
const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
const errors: unknown[] = []
|
||||
ctx.on('agent/error', (_agent, turn, step, error) => {
|
||||
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
|
||||
errors.push(error)
|
||||
})
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'error', step: 1, failure }])
|
||||
expect(reasons).toEqual([{ kind: 'error', error: failure }])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toBeInstanceOf(LlmError)
|
||||
expect((errors[0] as LlmError).failure).toEqual(failure)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
// The durable failure lives on turn/end.reason (with the failing step), not
|
||||
// a standalone error event.
|
||||
const turnEnd = events.find(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure })
|
||||
expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error', error: failure } } })
|
||||
// A failed step must not synthesize an assistant message.
|
||||
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
|
||||
})
|
||||
@@ -836,7 +621,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }])
|
||||
expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }])
|
||||
expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -854,7 +639,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }])
|
||||
expect(reasons).toEqual([{ kind: 'error', error: { message: 'codeless failure', code: 'UNKNOWN' } }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -944,8 +729,8 @@ describe('turn and step boundary recovery', () => {
|
||||
expect(stepEndIdx).toBeLessThan(turnEndIdx)
|
||||
})
|
||||
|
||||
it('a pre-commit turn/start rejection leaves no turn state for the next prompt', async () => {
|
||||
const adapter = new MockAdapter([textResponse('after recovery')])
|
||||
it('a pre-commit turn/start rejection leaves no durable turn state', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-turnstart-veto'), { provider: 'mock', model: 'mock' })
|
||||
let rejected = false
|
||||
@@ -965,23 +750,11 @@ describe('turn and step boundary recovery', () => {
|
||||
send(agent, 'rejected')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The rejected turn left nothing behind: no events, no admitted prompt.
|
||||
expect(agent.session.events).toEqual([])
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start'
|
||||
|| event.type === 'user/message')).toBe(false)
|
||||
expect(agent.inbox.nextTurn).toHaveLength(1)
|
||||
expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit'])
|
||||
|
||||
// The next prompt reuses the never-committed turn number and carries only
|
||||
// its own admitted content — invariants (mounted) accept the log.
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(boundaryCounts(agent)).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1 })
|
||||
const turnStart = agent.session.events.find(event => event.type === 'turn/start')
|
||||
expect(turnStart?.type === 'turn/start' && turnStart.data.turn).toBe(1)
|
||||
const prompts = agent.session.events.filter(event => event.type === 'user/message')
|
||||
expect(prompts.map(event => event.type === 'user/message' && event.data.content)).toEqual([
|
||||
[{ type: 'text', text: 'go' }],
|
||||
])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
|
||||
@@ -997,11 +770,6 @@ describe('turn and step boundary recovery', () => {
|
||||
throw new Error('reject step-start before commit')
|
||||
}
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
||||
if (error instanceof Error) errors.push(error)
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -1013,10 +781,12 @@ describe('turn and step boundary recovery', () => {
|
||||
stepEnd: 0,
|
||||
errors: 1,
|
||||
})
|
||||
expect(errors.map(error => error.message)).toEqual(['reject step-start before commit'])
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: { reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
|
||||
it('a step/end validation failure surfaces the resulting open-step invariant', async () => {
|
||||
const adapter = new MockAdapter([textResponse('completed before close validation')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' })
|
||||
@@ -1038,13 +808,16 @@ describe('turn and step boundary recovery', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(errors.map(error => error.message)).toEqual(['reject first step-end'])
|
||||
expect(errors.map(error => error.message)).toEqual([
|
||||
'reject first step-end',
|
||||
'invariant violated by "@deepseek-ai/dsh-session": turn/end 1 while step 1 is still open',
|
||||
])
|
||||
expect(boundaryCounts(agent)).toMatchObject({
|
||||
turnStart: 1,
|
||||
turnEnd: 1,
|
||||
turnEnd: 0,
|
||||
stepStart: 1,
|
||||
stepEnd: 1,
|
||||
errors: 1,
|
||||
stepEnd: 0,
|
||||
errors: 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1068,9 +841,9 @@ describe('turn and step boundary recovery', () => {
|
||||
expect(c.stepStart).toBe(c.stepEnd)
|
||||
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({
|
||||
kind: 'error',
|
||||
step: 1,
|
||||
failure: { message: 'provider 500', code: 'SERVER' },
|
||||
error: { message: 'provider 500', code: 'SERVER' },
|
||||
})
|
||||
expect(threw).toBe(true)
|
||||
|
||||
// loop survives: a second turn runs to completion (invariants oracle would
|
||||
// throw on its turn/start if turn 1 had been left open).
|
||||
@@ -1106,13 +879,12 @@ describe('turn and step boundary recovery', () => {
|
||||
const turnEnds = e.filter(x => x.type === 'turn/end').length
|
||||
expect(turnStarts).toBe(1)
|
||||
expect(turnEnds).toBe(1) // balanced — the turn was closed despite disposal
|
||||
expect(reasons).toEqual([{ kind: 'disposed' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
|
||||
// no error reason: disposal is not a failure.
|
||||
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
|
||||
// Disposal remains authoritative when the listener also throws.
|
||||
it('contains a pre-step throw after disposal inside a balanced no-step turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
let agent!: Agent
|
||||
@@ -1121,8 +893,8 @@ describe('turn and step boundary recovery', () => {
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/step', () => {
|
||||
if (threw) return
|
||||
ctx.on('agent/pre-step', (_subject, _messages, _context, next) => {
|
||||
if (threw) return next()
|
||||
threw = true
|
||||
void fiber.dispose()
|
||||
throw new Error('boom pre-step during disposal')
|
||||
@@ -1136,13 +908,10 @@ describe('turn and step boundary recovery', () => {
|
||||
await agent.whenIdle()
|
||||
|
||||
const e = [...agent.session.events]
|
||||
// Balanced: one turn/start, one turn/end carrying disposed (NOT error).
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
const turnEnd = e.findLast(x => x.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
|
||||
// No step opened (the throw was before step/start) and disposal is not a
|
||||
// failure, so no agent/error for the contained throw.
|
||||
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
|
||||
.toEqual(['turn/start', 'turn/end'])
|
||||
expect(e.find(x => x.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(errorEmits).toHaveLength(0)
|
||||
})
|
||||
@@ -1243,7 +1012,9 @@ describe('turn and step boundary recovery', () => {
|
||||
expect(e.some(x => x.type === 'step/end')).toBe(true)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(true)
|
||||
expect(e.at(-1)?.type).toBe('turn/end')
|
||||
expect(errors.map(error => error.message)).toEqual(['provider 500'])
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toBeInstanceOf(LlmError)
|
||||
expect((errors[0] as LlmError).failure).toEqual({ message: 'provider 500', code: 'SERVER' })
|
||||
|
||||
// loop survives.
|
||||
send(agent, 'again')
|
||||
@@ -1327,7 +1098,7 @@ describe('tool result call identity', () => {
|
||||
})
|
||||
|
||||
describe('disposal and cancellation during pre-step assembly', () => {
|
||||
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
|
||||
it('disposal during system-prompt assembly closes a no-step turn', { timeout: 30000 }, async () => {
|
||||
// Start disposal, then release assembly. Do not await disposal first: it
|
||||
// waits for the blocked driver to exit.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
@@ -1359,7 +1130,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
send(agent, 'go')
|
||||
// Give the loop time to enter the step and reach assemble().
|
||||
// Give the loop time to reach pre-step assembly.
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
// Release assembly before awaiting disposal because disposal joins the blocked driver.
|
||||
@@ -1370,17 +1141,16 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await driverDone(agent)
|
||||
unlisten()
|
||||
|
||||
// Turn boundaries are durable rows; there is no `agent/*` mirror to assert.
|
||||
const e = [...agent.session.events]
|
||||
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: 'disposed' })
|
||||
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
|
||||
.toEqual(['turn/start', 'turn/end'])
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'step/end')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
|
||||
})
|
||||
|
||||
it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => {
|
||||
it('cancel during system-prompt assembly closes a no-step turn', { timeout: 30000 }, async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not appear')])
|
||||
let releaseAssemble!: () => void
|
||||
const blocker = new Promise<void>(r => void (releaseAssemble = r))
|
||||
@@ -1419,20 +1189,18 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
unlisten()
|
||||
|
||||
const e = [...agent.session.events]
|
||||
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' })
|
||||
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
|
||||
.toEqual(['turn/start', 'turn/end'])
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'step/end')).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' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
})
|
||||
|
||||
it('disposal during agent/step listeners ends the turn disposed', { timeout: 15000 }, async () => {
|
||||
// Start disposal, then release pre-step; awaiting disposal first would
|
||||
// deadlock on the blocked driver.
|
||||
it('disposal during pre-step closes a no-step turn', { timeout: 15000 }, async () => {
|
||||
// Start disposal, then release pre-step; awaiting disposal first would deadlock on the blocked driver.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
let releasePreStep!: () => void
|
||||
const blocker = new Promise<void>(r => void (releasePreStep = r))
|
||||
@@ -1447,8 +1215,9 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/step', async () => {
|
||||
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: Agent
|
||||
@@ -1467,22 +1236,17 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await disposalDone
|
||||
await driverDone(agent)
|
||||
|
||||
// After the agent/step listeners finish, the post-listener cancel/dispose check
|
||||
// catches disposal. The step was never opened, no LLM call was made.
|
||||
// The post-listener cancellation check catches disposal before any step or LLM call.
|
||||
const e = [...agent.session.events]
|
||||
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')
|
||||
// Disposal wins the post-listener check — reason is `disposed`.
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
|
||||
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
|
||||
.toEqual(['turn/start', 'turn/end'])
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
// The durable turn/end record is the authoritative turn-boundary signal
|
||||
// (turn boundaries have no agent/* mirror).
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
|
||||
})
|
||||
|
||||
it('cancel during agent/step listeners ends the turn aborted', { timeout: 15000 }, async () => {
|
||||
// Release agent/step after cancellation to exercise the post-listener check.
|
||||
it('cancel during pre-step closes a no-step turn', { timeout: 15000 }, async () => {
|
||||
// Release pre-step after cancellation to exercise the post-listener check.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
let releasePreStep!: () => void
|
||||
const blocker = new Promise<void>(r => void (releasePreStep = r))
|
||||
@@ -1497,8 +1261,9 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await mountInvariants(ctx)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/step', async () => {
|
||||
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
|
||||
await blocker
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: Agent
|
||||
@@ -1519,13 +1284,11 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await driverDone(agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
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' })
|
||||
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
|
||||
.toEqual(['turn/start', 'turn/end'])
|
||||
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' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
})
|
||||
|
||||
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
|
||||
@@ -1565,14 +1328,12 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await driverDone(agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
|
||||
// The critical assertions: after disposal, the turn has no assistant
|
||||
// artifacts — the turn ended disposed before the model was invoked.
|
||||
expect(e.filter(x => x.type === 'turn/start' || x.type === 'turn/end').map(x => x.type))
|
||||
.toEqual(['turn/start', 'turn/end'])
|
||||
expect(e.find(x => x.type === 'turn/end')?.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
|
||||
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)
|
||||
// The durable turn/end reason is the authoritative turn-boundary record
|
||||
// (turn boundaries have no agent/* mirror).
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,19 +127,14 @@ describe('thrown-value propagation', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toBe('naked string error')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
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)
|
||||
// The rejected turn/start committed nothing, so the survivor reuses turn 1
|
||||
// and the rejected prompt does not leak into it.
|
||||
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' },
|
||||
])
|
||||
expect(starts).toHaveLength(0)
|
||||
expect(ends).toHaveLength(0)
|
||||
expect(messages).toHaveLength(0)
|
||||
expect(agent.inbox.nextTurn).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserves non-Error throws from the agent/request waterfall', async () => {
|
||||
@@ -156,22 +151,17 @@ describe('thrown-value propagation', () => {
|
||||
return next()
|
||||
})
|
||||
|
||||
const errors: unknown[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toEqual({ code: 500 })
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
||||
&& ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code))
|
||||
.toBeUndefined()
|
||||
? turnEnd.data.reason.error.message
|
||||
: undefined).toBe('[object Object]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('coded error data emission', () => {
|
||||
it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
|
||||
describe('durable error rendering', () => {
|
||||
it('renders a coded error thrown from a plugin', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -185,20 +175,16 @@ describe('coded error data emission', () => {
|
||||
return next()
|
||||
})
|
||||
|
||||
const errors: unknown[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errorChain(errors[0])).toBe('server overloaded')
|
||||
|
||||
// turn-end error reason includes the code
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd).toBeDefined()
|
||||
if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') {
|
||||
expect('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)
|
||||
.toBe('RATE_LIMIT')
|
||||
expect(turnEnd.data.reason.error).toEqual({
|
||||
message: 'server overloaded',
|
||||
code: 'RATE_LIMIT',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -221,7 +207,7 @@ describe('disposed vs aborted branching', () => {
|
||||
await driverDone(agent)
|
||||
|
||||
// Disposal wins abort classification because the error path checks it first.
|
||||
expect(reasons).toContainEqual({ kind: 'disposed' })
|
||||
expect(reasons).toContainEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -285,9 +271,7 @@ describe('request-error action edges', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/request-error', async (
|
||||
subject, _turn, _step, _error, _failure, _priorFailures, _retryPolicy, signal, next,
|
||||
) => {
|
||||
ctx.on('agent/request-error', async (subject, _context, signal, next) => {
|
||||
await next()
|
||||
subject.cancel({ kind: 'user' })
|
||||
expect(signal.aborted).toBe(true)
|
||||
@@ -480,48 +464,44 @@ describe('unrenderable failure settlement', () => {
|
||||
if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
|
||||
// The durable failure keeps the adapter facts' message, not the
|
||||
// unrenderable chain.
|
||||
expect(end.data.reason.failure?.message).not.toBe('<unrenderable value>')
|
||||
expect(errorChain(end.data.reason.error.message)).not.toBe('<unrenderable value>')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('driver bookkeeping edges', () => {
|
||||
it('a deferred wake settles when replacement activity rejects', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('rejected-deferred-wake'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
ctx.on('agent/inbox/enqueue', (subject) => {
|
||||
if (subject !== agent) return
|
||||
subject.cancel({ kind: 'user' })
|
||||
const mutable = subject as Agent & { done: Promise<void> }
|
||||
mutable.done = Promise.reject(new Error('replacement rejected'))
|
||||
})
|
||||
it('rejects a direct turn invocation without a driver reservation', async () => {
|
||||
const ctx = await harness(new MockAdapter([]))
|
||||
const agent = ctx.agentLoop.create(SessionId('turn-without-reservation'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'cancel before wake')
|
||||
|
||||
await expect(agent.whenIdle()).resolves.toBeUndefined()
|
||||
expect(agent.session.events).toEqual([])
|
||||
await expect((agent as unknown as { turn(): Promise<boolean> }).turn())
|
||||
.rejects.toThrow('turn without driver reservation')
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('a whenIdle waiter survives a rejected driver promise', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
it('closes an entered turn as blocked when its next step is rejected', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first step')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('waiter-chain'), { provider: 'mock', model: 'mock' })
|
||||
// A throwing terminal-notification listener rejects the driver promise
|
||||
// (the run's containment covers only session appends); the waiter's
|
||||
// catch arm must treat that rejection as quiescence instead of
|
||||
// propagating it.
|
||||
ctx.on('agent/settled', (subject) => {
|
||||
if (subject === agent) throw new Error('settled listener exploded')
|
||||
const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' })
|
||||
let proposals = 0
|
||||
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => {
|
||||
proposals += 1
|
||||
return proposals === 2 ? { kind: 'reject' } : next()
|
||||
})
|
||||
ctx.on('agent/turn-stopping', (subject) => {
|
||||
subject.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'do not enter the next step' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
})
|
||||
|
||||
send(agent, 'one')
|
||||
// Entered while the run owns the abort slot, the waiter awaits the
|
||||
// driver promise; its rejection must count as quiescence and resolve.
|
||||
await expect(agent.whenIdle()).resolves.toBeUndefined()
|
||||
send(agent, 'go')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(proposals).toBe(2)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const end = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'blocked' })
|
||||
})
|
||||
|
||||
it('a request failure that concludes recovery after step/end closed keeps the boundary balanced', async () => {
|
||||
|
||||
@@ -11,8 +11,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, {
|
||||
type Agent,
|
||||
type InboxPlacement,
|
||||
type PromptDecision,
|
||||
type PreStepDecision,
|
||||
type SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
@@ -20,7 +19,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
|
||||
* The interception seams introduced by the hooks taxonomy: `agent/pre-step`,
|
||||
* `agent/session-start`, `agent/turn-stopping`, and the
|
||||
* `tools/pre-execute` / `tools/post-execute`
|
||||
* split with `additionalContexts` buffering. These verify the canonical event
|
||||
@@ -59,15 +58,15 @@ function events(agent: Agent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
describe('agent/prompt-submit', () => {
|
||||
it('allow (default via next) records the user/message unchanged', async () => {
|
||||
describe('agent/pre-step', () => {
|
||||
it('enter (default via next) records the user/message unchanged', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
|
||||
seen.push(message.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
|
||||
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
|
||||
seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
|
||||
return next()
|
||||
})
|
||||
|
||||
@@ -79,16 +78,44 @@ describe('agent/prompt-submit', () => {
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
})
|
||||
|
||||
it('reports the request coordinates for initial and tool-continuation prompts', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'hi' }),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'echo',
|
||||
description: 'echo',
|
||||
parameters: { text: { type: 'string', required: true } },
|
||||
execute: async ({ text }) => [{ type: 'text', text }],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' })
|
||||
const seen: Array<{ turn: number; step: number; messages: number }> = []
|
||||
ctx.on('agent/pre-step', async (_agent, messages, context, next) => {
|
||||
seen.push({ turn: context.turn, step: context.step, messages: messages.length })
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'hello')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ turn: 1, step: 1, messages: 1 },
|
||||
{ turn: 1, step: 2, messages: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes frozen input without replacing its identity', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('owned-input'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
const decision = Promise.withResolvers<PreStepDecision>()
|
||||
const observed: UserMessage[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent) return
|
||||
const message = item.message
|
||||
ctx.on('agent/pre-step', async (subject, messages) => {
|
||||
if (subject !== agent) return { kind: 'enter', messages }
|
||||
const message = messages[0]!
|
||||
expect(Object.isFrozen(message)).toBe(true)
|
||||
expect(Object.isFrozen(message.content)).toBe(true)
|
||||
expect(Object.isFrozen(message.content[0])).toBe(true)
|
||||
@@ -97,11 +124,7 @@ describe('agent/prompt-submit', () => {
|
||||
const block = message.content[0]
|
||||
if (block?.type === 'text') block.text = 'listener mutation'
|
||||
}).toThrow()
|
||||
})
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent) observed.push(item.message)
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
observed.push(message)
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
@@ -120,11 +143,11 @@ describe('agent/prompt-submit', () => {
|
||||
expect(() => {
|
||||
if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation'
|
||||
}).toThrow(TypeError)
|
||||
decision.resolve({ kind: 'allow' })
|
||||
decision.resolve({ kind: 'enter', messages: [input] })
|
||||
await idle
|
||||
|
||||
expect(observed).toHaveLength(1)
|
||||
expect(observed[0]).toBe(input)
|
||||
expect(observed[0]).not.toBe(input)
|
||||
expect(observed[0]).toMatchObject({
|
||||
content: [{ type: 'text', text: 'accepted text' }],
|
||||
source: { kind: 'plugin', plugin: 'accepted source' },
|
||||
@@ -133,13 +156,16 @@ describe('agent/prompt-submit', () => {
|
||||
expect(userMsg?.type === 'user/message' && userMsg.data).toEqual(input)
|
||||
})
|
||||
|
||||
it('allow with content REWRITES the prompt before it is recorded', async () => {
|
||||
it('enter with content rewrites the prompt before it is recorded', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
|
||||
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
|
||||
({
|
||||
kind: 'enter',
|
||||
messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }],
|
||||
}))
|
||||
|
||||
send(agent, 'original')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -151,15 +177,15 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
|
||||
})
|
||||
|
||||
it('allow with additionalContexts injects separate injected-context user messages into the turn', async () => {
|
||||
it('enter with additional messages records separately sourced context in the turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
additionalContexts: [createUserMessage({
|
||||
kind: 'enter',
|
||||
messages: [...messages, createUserMessage({
|
||||
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
@@ -178,41 +204,39 @@ describe('agent/prompt-submit', () => {
|
||||
expect(sent).toContain('extra ctx')
|
||||
})
|
||||
|
||||
it('runs pre-step after prompt rewrites and injected context become durable', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
it('does not open another step when a completed turn rewrites pending input to empty', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
|
||||
additionalContexts: [createUserMessage({
|
||||
content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
const agent = ctx.agentLoop.create(SessionId('empty-completed-continuation'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
ctx.on('agent/turn-stopping', (subject) => {
|
||||
subject.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'pending context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
|
||||
let preStepDerived: string | undefined
|
||||
ctx.on('agent/step', (subject, _turn, step) => {
|
||||
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
|
||||
})
|
||||
ctx.on('agent/pre-step', async (_subject, _messages, context, next) => {
|
||||
const decision = await next()
|
||||
return context.step === 1 || decision.kind === 'reject'
|
||||
? decision
|
||||
: { kind: 'enter', messages: [] }
|
||||
})
|
||||
|
||||
send(agent, 'ORIGINAL prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'finish once')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(preStepDerived).toBeDefined()
|
||||
expect(preStepDerived).toContain('REWRITTEN prompt')
|
||||
expect(preStepDerived).toContain('injected ctx')
|
||||
expect(preStepDerived).not.toContain('ORIGINAL prompt')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('block drops the claimed prompt before any turn or model call', async () => {
|
||||
it('reject closes the claimed prompt turn without a step or model call', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({ kind: 'block', reason: 'blocked by policy' }))
|
||||
ctx.on('agent/pre-step', async (): Promise<PreStepDecision> => ({ kind: 'reject' }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -223,74 +247,81 @@ describe('agent/prompt-submit', () => {
|
||||
// the model was never called
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
const log = events(agent)
|
||||
expect(log.some(e => e.type === 'turn/start')).toBe(false)
|
||||
expect(log.some(e => e.type === 'turn/end')).toBe(false)
|
||||
expect(log.filter(e => e.type === 'turn/start' || e.type === 'turn/end').map(e => e.type))
|
||||
.toEqual(['turn/start', 'turn/end'])
|
||||
expect(log.some(e => e.type === 'user/message')).toBe(false)
|
||||
expect(log.some(e => e.type === 'step/start')).toBe(false)
|
||||
expect(reasons).toEqual([])
|
||||
expect(reasons).toEqual([{ kind: 'blocked' }])
|
||||
})
|
||||
|
||||
it('stages inject and steer during admission for the admitted turn', async () => {
|
||||
it('stages inject and steer during pre-step for the entered turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('admission-outbox'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('pre-step-outbox'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
const placements: InboxPlacement[] = []
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
const decision = Promise.withResolvers<PreStepDecision>()
|
||||
let claimed: UserMessage[] = []
|
||||
let firstProposal = true
|
||||
ctx.on('agent/pre-step', async (_agent, messages) => {
|
||||
if (!firstProposal) return { kind: 'enter', messages }
|
||||
firstProposal = false
|
||||
claimed = messages
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent) placements.push(item.placement)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'admitted prompt')
|
||||
send(agent, 'entered prompt')
|
||||
await entered.promise
|
||||
expect(agent.status).toBe('running')
|
||||
expect(agent.acceptsNextStep).toBe(true)
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(true)
|
||||
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'attached context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'admission steering' }], source: { kind: 'user' } }))
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'pre-step steering' }], source: { kind: 'user' } }))
|
||||
expect(events(agent).some(event => event.type === 'user/message')).toBe(false)
|
||||
expect(placements).toEqual(['queued', 'steering'])
|
||||
expect(agent.inbox.nextStep.map(message => message.content[0]))
|
||||
.toEqual([
|
||||
{ type: 'text', text: 'attached context' },
|
||||
{ type: 'text', text: 'pre-step steering' },
|
||||
])
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
decision.resolve({ kind: 'enter', messages: claimed })
|
||||
await idle
|
||||
expect(agent.acceptsNextStep).toBe(false)
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
|
||||
const staged = events(agent).filter(event =>
|
||||
event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
|
||||
event.type === 'turn/start' || event.type === 'user/message')
|
||||
expect(staged.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'user/message',
|
||||
'steering/message',
|
||||
'user/message',
|
||||
])
|
||||
expect(staged[1]?.type === 'user/message' && staged[1].data.content)
|
||||
.toEqual([{ type: 'text', text: 'admitted prompt' }])
|
||||
.toEqual([{ type: 'text', text: 'entered prompt' }])
|
||||
expect(staged[2]?.type === 'user/message' && staged[2].data.content)
|
||||
.toEqual([{ type: 'text', text: 'attached context' }])
|
||||
expect(staged[3]?.type === 'steering/message' && staged[3].data.message.content)
|
||||
.toEqual([{ type: 'text', text: 'admission steering' }])
|
||||
const request = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(request).toContain('admitted prompt')
|
||||
expect(request).toContain('attached context')
|
||||
expect(request).toContain('admission steering')
|
||||
expect(staged[3]?.type === 'user/message' && staged[3].data.content)
|
||||
.toEqual([{ type: 'text', text: 'pre-step steering' }])
|
||||
const firstRequest = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(firstRequest).toContain('entered prompt')
|
||||
expect(firstRequest).not.toContain('attached context')
|
||||
expect(firstRequest).not.toContain('pre-step steering')
|
||||
const nextRequest = JSON.stringify(adapter.requests[1]?.messages)
|
||||
expect(nextRequest).toContain('attached context')
|
||||
expect(nextRequest).toContain('pre-step steering')
|
||||
})
|
||||
|
||||
it('keeps admission-time outbox input staged when admission is blocked', async () => {
|
||||
it('preserves input staged after the blocked batch was claimed', async () => {
|
||||
const adapter = new MockAdapter([textResponse('retried')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-pre-step-outbox'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
|
||||
const decision = Promise.withResolvers<PreStepDecision>()
|
||||
const disposeBlock = ctx.on('agent/pre-step', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
@@ -298,17 +329,21 @@ describe('agent/prompt-submit', () => {
|
||||
const blockedIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'blocked prompt')
|
||||
await entered.promise
|
||||
expect(agent.acceptsNextStep).toBe(true)
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'staged context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } }))
|
||||
decision.resolve({ kind: 'block', reason: 'policy' })
|
||||
decision.resolve({ kind: 'reject' })
|
||||
await blockedIdle
|
||||
|
||||
expect(agent.acceptsNextStep).toBe(false)
|
||||
expect(events(agent)).toEqual([])
|
||||
expect(agent.inbox.nextStep.map(message => message.content[0]))
|
||||
.toEqual([
|
||||
{ type: 'text', text: 'staged context' },
|
||||
{ type: 'text', text: 'staged steering' },
|
||||
])
|
||||
expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'turn/end')
|
||||
.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||
expect(adapter.requests).toEqual([])
|
||||
|
||||
disposeBlock()
|
||||
@@ -316,10 +351,10 @@ describe('agent/prompt-submit', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const staged = events(agent).filter(event =>
|
||||
event.type === 'user/message' || event.type === 'steering/message')
|
||||
event.type === 'user/message')
|
||||
expect(staged.map(event => event.type)).toEqual([
|
||||
'user/message',
|
||||
'steering/message',
|
||||
'user/message',
|
||||
'user/message',
|
||||
])
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt')
|
||||
@@ -327,21 +362,26 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
|
||||
})
|
||||
|
||||
it('orders rejected-admission outbox input before a later admitted prompt', async () => {
|
||||
const adapter = new MockAdapter([textResponse('continued')])
|
||||
it('preserves later queued work when a step is rejected', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('continued'),
|
||||
textResponse('wake reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('rejected-admission-order'), {
|
||||
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-order'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async (_agent, message, _signal, next) => {
|
||||
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
|
||||
const decision = await next()
|
||||
return message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')
|
||||
? { kind: 'block', reason: 'policy' }
|
||||
return messages.some(message =>
|
||||
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))
|
||||
? { kind: 'reject' as const }
|
||||
: decision
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async (subject, message, _signal, next) => {
|
||||
if (message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) {
|
||||
ctx.on('agent/pre-step', async (subject, messages, _signal, next) => {
|
||||
if (messages.some(message =>
|
||||
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) {
|
||||
subject.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'earlier state change' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
@@ -359,29 +399,34 @@ describe('agent/prompt-submit', () => {
|
||||
send(agent, 'later prompt')
|
||||
await idle
|
||||
|
||||
const staged = events(agent).filter(event =>
|
||||
event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
|
||||
expect(staged.map(event => event.type)).toEqual([
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'steering/message',
|
||||
'user/message',
|
||||
])
|
||||
expect(staged[1]?.type === 'user/message' && staged[1].data.content)
|
||||
.toEqual([{ type: 'text', text: 'earlier state change' }])
|
||||
expect(staged[2]?.type === 'steering/message' && staged[2].data.message.content)
|
||||
.toEqual([{ type: 'text', text: 'earlier steering' }])
|
||||
expect(staged[3]?.type === 'user/message' && staged[3].data.content)
|
||||
expect(events(agent).filter(event => event.type === 'turn/start' || event.type === 'turn/end')
|
||||
.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||
expect(agent.inbox.nextStep.map(message => message.content[0]))
|
||||
.toEqual([
|
||||
{ type: 'text', text: 'earlier state change' },
|
||||
{ type: 'text', text: 'earlier steering' },
|
||||
])
|
||||
expect(agent.inbox.nextTurn.map(message => message.content[0]))
|
||||
.toEqual([{ type: 'text', text: 'later prompt' }])
|
||||
expect(adapter.requests).toEqual([])
|
||||
|
||||
const resumed = waitForIdle(ctx, agent)
|
||||
send(agent, 'wake')
|
||||
await resumed
|
||||
const request = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(request).toContain('earlier state change')
|
||||
expect(request).toContain('earlier steering')
|
||||
expect(request).toContain('later prompt')
|
||||
expect(request).not.toContain('blocked prompt')
|
||||
})
|
||||
|
||||
it('commits context-only injection when admission closes without a turn', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
it('preserves context-only injection staged after pre-step began', async () => {
|
||||
const adapter = new MockAdapter([textResponse('continued')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-admission-context'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-context'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
const decision = Promise.withResolvers<PreStepDecision>()
|
||||
const disposeBlock = ctx.on('agent/pre-step', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
@@ -393,91 +438,90 @@ describe('agent/prompt-submit', () => {
|
||||
content: [{ type: 'text', text: 'independent context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
decision.resolve({ kind: 'block', reason: 'policy' })
|
||||
decision.resolve({ kind: 'reject' })
|
||||
await idle
|
||||
|
||||
const log = events(agent)
|
||||
expect(log.map(event => event.type)).toEqual(['user/message'])
|
||||
expect(log[0]?.type === 'user/message' && log[0].data.content)
|
||||
expect(log.some(event => event.type === 'user/message')).toBe(false)
|
||||
expect(agent.inbox.nextStep.map(message => message.content[0]))
|
||||
.toEqual([{ type: 'text', text: 'independent context' }])
|
||||
expect(adapter.requests).toEqual([])
|
||||
|
||||
disposeBlock()
|
||||
const resumed = waitForIdle(ctx, agent)
|
||||
send(agent, 'wake')
|
||||
await resumed
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('independent context')
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt')
|
||||
})
|
||||
|
||||
it('retains rejected-admission context when its idle append fails', async () => {
|
||||
const adapter = new MockAdapter([textResponse('retried')])
|
||||
it('leaves inbox state unchanged when its durable append fails', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('blocked-admission-append-failure'), {
|
||||
const agent = ctx.agentLoop.create(SessionId('rejected-pre-step-append-failure'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
vi.spyOn(agent.session, 'append').mockImplementationOnce(() => {
|
||||
throw new Error('append unavailable')
|
||||
})
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<PromptDecision>()
|
||||
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' } }))
|
||||
await entered.promise
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'retained context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
decision.resolve({ kind: 'block', reason: 'policy' })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(() => {
|
||||
send(agent, 'blocked prompt')
|
||||
}).toThrow('append unavailable')
|
||||
expect(events(agent)).toEqual([])
|
||||
expect(warned).toHaveBeenCalledWith(expect.stringContaining('append unavailable'))
|
||||
|
||||
disposeBlock()
|
||||
send(agent, 'resume')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(events(agent).some(event => event.type === 'user/message'
|
||||
&& JSON.stringify(event.data.content).includes('retained context'))).toBe(true)
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ran once')])
|
||||
it('a blocked prompt preserves adjacent queued prompts', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('safe reply'),
|
||||
textResponse('wake reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, message, _signal, next): Promise<PromptDecision> => {
|
||||
const text = message.content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
|
||||
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
|
||||
const text = messages.flatMap(message => message.content)
|
||||
.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
return text === 'secret'
|
||||
? { kind: 'reject' }
|
||||
: next()
|
||||
})
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
// The rejected admission is dropped; the allowed prompt owns the only turn.
|
||||
send(agent, 'secret')
|
||||
send(agent, 'safe')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
// The allowed prompt became a user/message and drove exactly one model call.
|
||||
const userMsgs = log.filter(e => e.type === 'user/message')
|
||||
expect(userMsgs).toHaveLength(1)
|
||||
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
|
||||
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
|
||||
expect(log.filter(e => e.type === 'user/message')).toHaveLength(0)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(reasons).toEqual([{ kind: 'completed' }])
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
|
||||
expect(reasons).toEqual([{ kind: 'blocked' }])
|
||||
expect(agent.inbox.nextTurn.map(message => message.content[0]))
|
||||
.toEqual([{ type: 'text', text: 'safe' }])
|
||||
|
||||
const resumed = waitForIdle(ctx, agent)
|
||||
send(agent, 'wake')
|
||||
await resumed
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('safe')
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('a throwing prompt-submit listener drops that admission while an adjacent message survives', async () => {
|
||||
it('a throwing pre-step listener reports the driver error and retains adjacent work', async () => {
|
||||
const adapter = new MockAdapter([textResponse('after')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
ctx.on('agent/pre-step', async (_agent, messages) => {
|
||||
if (!threw) { threw = true; throw new Error('prompt hook broke') }
|
||||
return { kind: 'allow' as const }
|
||||
return { kind: 'enter' as const, messages }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -494,14 +538,18 @@ describe('agent/prompt-submit', () => {
|
||||
send(agent, 'first')
|
||||
send(agent, 'second')
|
||||
await idle
|
||||
expect(errors).toEqual([])
|
||||
expect(errors).toEqual([expect.objectContaining({ message: 'prompt hook broke' })])
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
|
||||
expect(reasons).toEqual([{ kind: 'completed' }])
|
||||
expect(reasons).toEqual([{
|
||||
kind: 'error',
|
||||
error: { message: 'prompt hook broke', code: 'UNKNOWN' },
|
||||
}])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.inbox.nextTurn.map(message => message.content[0]))
|
||||
.toEqual([{ type: 'text', text: 'second' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -679,10 +727,13 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }))
|
||||
})
|
||||
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
|
||||
ctx.on('agent/prompt-submit', async (_agent, message, _signal, next): Promise<PromptDecision> => {
|
||||
const text = message.content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
|
||||
// 2. PreStep: reject a forbidden prompt, annotate the rest.
|
||||
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => {
|
||||
const text = messages.flatMap(message => message.content)
|
||||
.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
if (text.includes('rm -rf')) {
|
||||
return { kind: 'reject' }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
// 3. PreToolUse: deny a dangerous tool by name.
|
||||
@@ -730,7 +781,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
|
||||
})
|
||||
|
||||
it('the same plugin blocks a destructive prompt before a turn or model call', async () => {
|
||||
it('the same plugin blocks a destructive prompt inside a no-step turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(NativeGuard)
|
||||
@@ -743,7 +794,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(reasons).toEqual([])
|
||||
expect(reasons).toEqual([{ kind: 'blocked' }])
|
||||
})
|
||||
|
||||
it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => {
|
||||
@@ -756,7 +807,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'run rm -rf /')
|
||||
await waitForIdle(ctx, agent)
|
||||
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
|
||||
// the prompt ran (not rejected) — proving the pre-step listener was disposed
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ function loopRequest<T extends object>(options: T): Readonly<T> {
|
||||
async function requestSetup() {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-check'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -42,12 +42,16 @@ describe('request-reconstruction invariant', () => {
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('uses the step boundary rather than content appended afterward', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
it('includes context appended inside the open step before dispatch', async () => {
|
||||
const { ctx, session } = await requestSetup()
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' },
|
||||
content: [{ type: 'text', text: '[step context]' }], source: { kind: 'plugin', plugin: 'x' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
|
||||
const options = loopRequest({
|
||||
model: 'm',
|
||||
messages: Object.freeze(session.deriveMessages()),
|
||||
sessionId: session.id,
|
||||
})
|
||||
expect(() => { dispatch(ctx, options) }).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -57,16 +61,16 @@ describe('request-reconstruction invariant', () => {
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
|
||||
.not.toThrow()
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([extra, ...boundary]), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
.toThrow(/diverges from the dispatch-time durable derivation/)
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, extra]), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
.toThrow(/diverges from the dispatch-time durable derivation/)
|
||||
})
|
||||
|
||||
it('rejects message and header divergence', async () => {
|
||||
const { ctx, session, boundary } = await requestSetup()
|
||||
const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the boundary derivation/)
|
||||
.toThrow(/diverges from the dispatch-time durable derivation/)
|
||||
expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
|
||||
.toThrow(/diverges from the folded request header/)
|
||||
})
|
||||
@@ -74,7 +78,7 @@ describe('request-reconstruction invariant', () => {
|
||||
it('rejects loop requests with no boundary or header', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('req-bare'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
|
||||
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
@@ -122,7 +126,7 @@ describe('request-reconstruction invariant', () => {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
const session = ctx.sessions.create(SessionId('prepend-check'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
@@ -133,6 +137,6 @@ describe('request-reconstruction invariant', () => {
|
||||
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
|
||||
sessionId: session.id,
|
||||
})
|
||||
expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/)
|
||||
expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the dispatch-time durable derivation/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { createUserMessage, CallId, LlmError, 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, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
@@ -25,11 +25,7 @@ async function harness(adapter: MockAdapter, persona = '') {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the agent's NEXT transition to idle. Always event-based: callers
|
||||
* invoke this right after send(), when the loop hasn't woken yet (status is
|
||||
* still 'idle' synchronously), so polling the current status would lie.
|
||||
*/
|
||||
/** Wait for the agent's next transition to idle after a waking send. */
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
@@ -73,6 +69,35 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests[0]?.maxTokens).toBe(256)
|
||||
})
|
||||
|
||||
it('cancels queued wakeup work together with an active maintenance task', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const maintenance = agent.runMaintenance(async (signal) => {
|
||||
started.resolve(undefined)
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new Error('maintenance aborted', { cause: signal.reason }))
|
||||
}, { once: true })
|
||||
})
|
||||
})
|
||||
await started.promise
|
||||
|
||||
send(agent, 'discard this wakeup')
|
||||
agent.cancel({ kind: 'user' })
|
||||
send(agent, 'park after cancellation')
|
||||
|
||||
await expect(maintenance).rejects.toThrow('maintenance aborted')
|
||||
await agent.whenIdle()
|
||||
expect(agent.inbox.nextTurn).toHaveLength(1)
|
||||
expect(adapter.requests).toEqual([])
|
||||
agent.cancel({ kind: 'user' })
|
||||
})
|
||||
|
||||
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hello there')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -94,11 +119,10 @@ describe('agent loop', () => {
|
||||
expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
// turn/start opens the turn, THEN the queued user message is recorded inside
|
||||
// it (every event is turn-enclosed), then the assembled message (carrying the
|
||||
// step's usage).
|
||||
expect(types[0]).toBe('turn/start')
|
||||
expect(types[1]).toBe('user/message')
|
||||
// Durable inbox receipt precedes the turn-owned transcript.
|
||||
expect(types[0]).toBe('agent/inbox/spliced')
|
||||
expect(types).toContain('turn/start')
|
||||
expect(types).toContain('user/message')
|
||||
expect(types).toContain('assistant/message')
|
||||
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
|
||||
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data.usage).toEqual({ inputTokens: 10, outputTokens: 'hello there'.length })
|
||||
@@ -201,9 +225,14 @@ describe('agent loop', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0) // the request was never sent
|
||||
expect(errors.some(e => e.message.includes('no value for this assembly'))).toBe(true)
|
||||
expect(errors.map(error => error.message)).toEqual([
|
||||
'prompt variable "{{cwd}}" has no value for this assembly (section "deployment:persona")',
|
||||
])
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
||||
? turnEnd.data.reason.error.message
|
||||
: '').toContain('no value for this assembly')
|
||||
|
||||
// The loop survived: a waterfall listener rescues {{cwd}} and the SAME
|
||||
// agent completes a real model turn.
|
||||
@@ -470,10 +499,7 @@ describe('agent loop', () => {
|
||||
parameters: {},
|
||||
async execute() {
|
||||
// steer while the turn is running (during tool execution)
|
||||
agent.send(
|
||||
createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }),
|
||||
{ target: 'next-step', wakeup: true },
|
||||
)
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }))
|
||||
return [{ type: 'text', text: 'tool done' }]
|
||||
},
|
||||
}))
|
||||
@@ -481,13 +507,15 @@ describe('agent loop', () => {
|
||||
send(agent, 'start')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toContain('steering/message')
|
||||
// steering recorded before the second step's request derived its history
|
||||
const steeringSeq = agent.session.events.find(e => e.type === 'steering/message')!.seq
|
||||
const steering = agent.session.events.find(e =>
|
||||
e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans'))
|
||||
expect(steering).toBeDefined()
|
||||
// The entered batch is appended after the second step opens and before its
|
||||
// request derives history.
|
||||
const steeringSeq = steering!.seq
|
||||
const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1]
|
||||
expect(secondStepStart).toBeDefined()
|
||||
expect(steeringSeq).toBeLessThan(secondStepStart!.seq)
|
||||
expect(steeringSeq).toBeGreaterThan(secondStepStart!.seq)
|
||||
|
||||
// the second model request saw the steering content
|
||||
const secondRequest = adapter.requests[1]
|
||||
@@ -495,40 +523,41 @@ describe('agent loop', () => {
|
||||
expect(flat).toContain('change of plans')
|
||||
})
|
||||
|
||||
it('same-tick idle steering preserves one turn per send', async () => {
|
||||
it('starts idle steering synchronously and enters later steering at the next step', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'first idle steer' }], source: { kind: 'user' } }))
|
||||
expect(agent.status).toBe('running')
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'second idle steer' }], source: { kind: 'user' } }))
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)).toEqual([
|
||||
[{ type: 'text', text: 'first idle steer' }],
|
||||
[{ type: 'text', text: 'second idle steer' }],
|
||||
])
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('first idle steer')
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('second idle steer')
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('second idle steer')
|
||||
})
|
||||
|
||||
it('keeps steering staged after a failed step until the next admitted turn', async () => {
|
||||
it('stops after a throwing pre-step listener and retains later steering until a wakeup', async () => {
|
||||
const adapter = new MockAdapter([textResponse('recovered')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
|
||||
let fail = true
|
||||
ctx.on('agent/step', (subject) => {
|
||||
if (subject !== agent || !fail) return
|
||||
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
|
||||
if (subject !== agent || !fail) return next()
|
||||
fail = false
|
||||
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } }))
|
||||
throw new Error('step failed')
|
||||
throw new Error('pre-step failed')
|
||||
})
|
||||
|
||||
send(agent, 'prompt')
|
||||
@@ -536,132 +565,18 @@ describe('agent loop', () => {
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(1)
|
||||
expect(agent.inbox.nextStep).toHaveLength(1)
|
||||
|
||||
send(agent, 'resume')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true)
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
|
||||
})
|
||||
|
||||
it('rejects failed steering commits while preserving later context', async () => {
|
||||
const adapter = new MockAdapter([textResponse('recovered')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('failed-steering-commit'), { provider: 'mock', model: 'mock' })
|
||||
let receipt: ReturnType<Agent['steer']> | undefined
|
||||
ctx.on('agent/step', (subject) => {
|
||||
if (subject !== agent || receipt !== undefined) return
|
||||
receipt = subject.steer(createUserMessage({
|
||||
content: [{ type: 'text', text: 'rejected steering' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
subject.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'preserved context' }],
|
||||
source: { kind: 'plugin', plugin: 'loop-test' },
|
||||
}))
|
||||
})
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as { type: string }
|
||||
if (event.type === 'steering/message' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('reject steering commit')
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'first prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
if (receipt === undefined) throw new Error('agent/step did not submit steering')
|
||||
expect(await receipt.outcome).toEqual({ status: 'rejected' })
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
|
||||
send(agent, 'recover')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const request = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(request).toContain('preserved context')
|
||||
expect(request).not.toContain('rejected steering')
|
||||
})
|
||||
|
||||
it('rejects committed steering when the step boundary fails', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('failed-step-boundary'), { provider: 'mock', model: 'mock' })
|
||||
let receipt: ReturnType<Agent['steer']> | undefined
|
||||
ctx.on('agent/step', (subject) => {
|
||||
if (subject !== agent || receipt !== undefined) return
|
||||
receipt = subject.steer(createUserMessage({
|
||||
content: [{ type: 'text', text: 'committed steering' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
})
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as { type: string }
|
||||
if (event.type === 'step/start') throw new Error('reject step boundary')
|
||||
})
|
||||
|
||||
send(agent, 'prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
if (receipt === undefined) throw new Error('agent/step did not submit steering')
|
||||
expect(await receipt.outcome).toEqual({ status: 'rejected' })
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true)
|
||||
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('retries context and steering after a context commit fails', async () => {
|
||||
const adapter = new MockAdapter([textResponse('recovered')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('failed-context-commit'), { provider: 'mock', model: 'mock' })
|
||||
let receipt: ReturnType<Agent['steer']> | undefined
|
||||
ctx.on('agent/step', (subject) => {
|
||||
if (subject !== agent || receipt !== undefined) return
|
||||
subject.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'preserved context' }],
|
||||
source: { kind: 'plugin', plugin: 'loop-test' },
|
||||
}))
|
||||
receipt = subject.steer(createUserMessage({
|
||||
content: [{ type: 'text', text: 'preserved steering' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
})
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as { type: string; data?: { source?: { kind: string } } }
|
||||
if (event.type === 'user/message' && event.data?.source?.kind === 'plugin' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('reject context commit')
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'first prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
|
||||
send(agent, 'recover')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
if (receipt === undefined) throw new Error('agent/step did not submit steering')
|
||||
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 2, step: 1 })
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const request = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(request).toContain('preserved context')
|
||||
expect(request).toContain('preserved steering')
|
||||
})
|
||||
|
||||
it('inject() while idle appends context without opening a turn', async () => {
|
||||
it('inject() while idle durably stages context without opening a turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
@@ -671,11 +586,14 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'user/message',
|
||||
type: 'agent/inbox/spliced',
|
||||
data: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
source: { kind: 'plugin', plugin: 'watcher' },
|
||||
target: 'next-step',
|
||||
inserted: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
source: { kind: 'plugin', plugin: 'watcher' },
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -736,8 +654,6 @@ describe('agent loop', () => {
|
||||
// split the assistant tool call from the provider's tool-result message.
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(turnStarts).toHaveLength(1)
|
||||
const ts0 = turnStarts[0]!
|
||||
expect(ts0.type === 'turn/start' && ts0.data.trigger.kind).toBe('message')
|
||||
const result = agent.session.events.find(e => e.type === 'tool/result')!
|
||||
const contexts = agent.session.events.filter(e => e.type === 'user/message' && e.data.source.kind === 'plugin')
|
||||
expect(contexts).toHaveLength(2)
|
||||
@@ -831,31 +747,20 @@ describe('agent loop', () => {
|
||||
expect(agent.session.events.some(e => e.type === 'tool/result')).toBe(true)
|
||||
})
|
||||
|
||||
it('a concluding tool result beats steering that arrived during the same step', async () => {
|
||||
it('continues for steering that arrived during a concluding tool step', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'finalize', {}),
|
||||
textResponse('next turn reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let receipt: ReturnType<Agent['steer']> | undefined
|
||||
let contextInjected = false
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'step/end' || contextInjected) return
|
||||
contextInjected = true
|
||||
agent.inject(createUserMessage({
|
||||
content: [{ type: 'text', text: 'final context' }],
|
||||
source: { kind: 'plugin', plugin: 'finalize' },
|
||||
}))
|
||||
})
|
||||
ctx.tools.register(defineContentToolFixture({
|
||||
name: 'finalize',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
// Steering lands while the concluding tool is still executing; the
|
||||
// step/end listener adds ordinary context after the normal result drain.
|
||||
receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
|
||||
// Steering lands while the concluding tool is still executing.
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
|
||||
exec.concludeTurn()
|
||||
return [{ type: 'text', text: 'final' }]
|
||||
},
|
||||
@@ -864,26 +769,15 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The terminal result stands: no extra request reopens the concluded turn.
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const events = agent.session.events.map(event => event.type)
|
||||
expect(events.filter(type => type === 'turn/end')).toHaveLength(1)
|
||||
if (receipt === undefined) throw new Error('concluding tool did not submit steering')
|
||||
expect(await receipt.outcome).toEqual({ status: 'rejected' })
|
||||
expect(events).not.toContain('steering/message')
|
||||
expect(agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text === 'final context'))).toBe(true)
|
||||
|
||||
send(agent, 'follow up')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('late steering')
|
||||
const texts = adapter.requests[1]!.messages
|
||||
.flatMap(message => message.content)
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
expect(texts).toContain('final context')
|
||||
expect(texts).not.toContain('late steering')
|
||||
expect(texts).toContain('late steering')
|
||||
})
|
||||
|
||||
it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => {
|
||||
@@ -909,7 +803,7 @@ describe('agent loop', () => {
|
||||
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.config.model).toBe('other-model')
|
||||
})
|
||||
|
||||
it('agent/step fires once per step before the step is opened', async () => {
|
||||
it('agent/pre-step fires once per proposed step before the step is opened', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', {}, 'calling echo'),
|
||||
textResponse('done'),
|
||||
@@ -922,8 +816,9 @@ describe('agent loop', () => {
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
|
||||
ctx.on('agent/step', (subject, turn, step, signal) => {
|
||||
ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => {
|
||||
if (subject === agent) fires.push({ turn, step, signal })
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -936,50 +831,33 @@ describe('agent loop', () => {
|
||||
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
|
||||
})
|
||||
|
||||
it('agent/step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
|
||||
// The append lands before step/start, yet derive happens afterwards and the
|
||||
// same step's request must include it.
|
||||
it('agent/pre-step fires before its step boundary opens and before the request', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let injected = false
|
||||
ctx.on('agent/step', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
injected = true
|
||||
subject.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'INJECTED-IN-PRE-STEP' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
let boundaryOpen = true
|
||||
ctx.on('agent/pre-step', (subject, _messages, _context, next) => {
|
||||
if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start'
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The adapter's request includes the node injected during pre-step (derive
|
||||
// reflects it).
|
||||
const text = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(text).toContain('INJECTED-IN-PRE-STEP')
|
||||
|
||||
// And the injected event sits BEFORE the first step/start in the log —
|
||||
// the seam fired outside the step.
|
||||
const events = agent.session.events
|
||||
const injectedSeq = events.find(e => e.type === 'user/message' && e.data.source.kind === 'plugin')!.seq
|
||||
const firstStepStartSeq = events.find(e => e.type === 'step/start')!.seq
|
||||
expect(injectedSeq).toBeLessThan(firstStepStartSeq)
|
||||
expect(boundaryOpen).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a throwing agent/step listener ends the turn (error), not the loop', async () => {
|
||||
// Before step/start, a pre-step throw reaches the turn catch: no step needs
|
||||
// closing, the turn records error, and the loop remains available.
|
||||
it('a throwing agent/pre-step listener fails the proposal, not the loop', async () => {
|
||||
const adapter = new MockAdapter([textResponse('second turn ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let throwOnce = true
|
||||
ctx.on('agent/step', () => {
|
||||
ctx.on('agent/pre-step', (_agent, _messages, _context, next) => {
|
||||
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
|
||||
return next()
|
||||
})
|
||||
|
||||
const errors: Error[] = []
|
||||
@@ -989,16 +867,11 @@ describe('agent loop', () => {
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
// The first turn failed at step 1 (no model call happened), surfaced via
|
||||
// agent/error, with the durable failure on turn/end.reason.
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.message).toContain('boom in pre-step')
|
||||
// The first proposal failed inside a balanced turn without calling the model.
|
||||
expect(errors.map(error => error.message)).toEqual(['boom in pre-step'])
|
||||
expect(adapter.requests.length).toBe(0)
|
||||
const firstTurnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(firstTurnEnd?.type === 'turn/end' && firstTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 })
|
||||
// The step opened-and-closed count stays balanced even though it never ran.
|
||||
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)
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(true)
|
||||
expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(true)
|
||||
|
||||
// The loop survived: a second prompt runs a normal completed turn.
|
||||
send(agent, 'second')
|
||||
@@ -1023,7 +896,7 @@ describe('agent loop', () => {
|
||||
agent.cancel({ kind: 'user' })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
})
|
||||
|
||||
it('surfaces max-tokens as the turn-end reason when the last step is cut off', async () => {
|
||||
@@ -1094,6 +967,8 @@ describe('agent loop', () => {
|
||||
source: { kind: 'plugin', plugin: 'max-tokens-test' },
|
||||
},
|
||||
])
|
||||
// A max-token step is sticky: the later completed step must not
|
||||
// downgrade the turn outcome.
|
||||
expect(reasons).toEqual([{ kind: 'max-tokens' }])
|
||||
})
|
||||
|
||||
@@ -1311,14 +1186,15 @@ describe('agent loop', () => {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('keeps a reentrant agent/inbox/enqueue send as the next independent turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
it('contains a reentrant send attempted during durable inbox publication', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let nested = false
|
||||
ctx.on('agent/inbox/enqueue', (subject) => {
|
||||
if (subject !== agent || nested) return
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'agent/inbox/spliced'
|
||||
|| event.data.inserted.length === 0 || nested) return
|
||||
nested = true
|
||||
send(agent, 'queued listener message')
|
||||
})
|
||||
@@ -1331,11 +1207,8 @@ describe('agent loop', () => {
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)
|
||||
expect(turns).toHaveLength(2)
|
||||
expect(messages).toEqual([
|
||||
[{ type: 'text', text: 'outer message' }],
|
||||
[{ type: 'text', text: 'queued listener message' }],
|
||||
])
|
||||
expect(turns).toHaveLength(1)
|
||||
expect(messages).toEqual([[{ type: 'text', text: 'outer message' }]])
|
||||
})
|
||||
|
||||
it('preserves independent turn sources across an adjacent microtask send', async () => {
|
||||
@@ -1349,16 +1222,11 @@ describe('agent loop', () => {
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'plugin message' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
await idle
|
||||
|
||||
const triggers = agent.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.map(event => event.data.trigger)
|
||||
const turns = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const sources = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.source)
|
||||
expect(triggers).toEqual([
|
||||
{ kind: 'message', source: { kind: 'user' } },
|
||||
{ kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
|
||||
])
|
||||
expect(turns).toHaveLength(2)
|
||||
expect(sources).toEqual([
|
||||
{ kind: 'user' },
|
||||
{ kind: 'plugin', plugin: 'test' },
|
||||
@@ -1379,7 +1247,7 @@ describe('agent loop', () => {
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'assistant/chunk' && !queued) {
|
||||
queued = true
|
||||
send(agent, 'second message')
|
||||
queueMicrotask(() => { send(agent, 'second message') })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1421,15 +1289,15 @@ describe('agent loop', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('errors from the model surface as agent/error and end the turn', async () => {
|
||||
it('records normalized model errors on the turn boundary', async () => {
|
||||
const adapter = new MockAdapter([]) // script exhausted → throws
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const errors: Error[] = []
|
||||
const errors: unknown[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
||||
if (error instanceof Error) errors.push(error)
|
||||
errors.push(error)
|
||||
})
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
@@ -1437,12 +1305,15 @@ describe('agent loop', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.message).toContain('script exhausted')
|
||||
expect(errors[0]).toBeInstanceOf(LlmError)
|
||||
expect((errors[0] as LlmError).failure).toEqual({
|
||||
message: 'MockAdapter: script exhausted',
|
||||
code: 'UNKNOWN',
|
||||
})
|
||||
expect(reasons[0]).toMatchObject({ kind: 'error' })
|
||||
// The durable failure lives entirely on turn/end.reason (with the failing
|
||||
// step), not a standalone error event.
|
||||
// The durable failure and live relay describe the same failed turn.
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 })
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error' })
|
||||
})
|
||||
|
||||
it('disposing the loop fiber mid-turn stops the loop (HMR safety)', async () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ function userMessageTexts(agent: Agent): string[] {
|
||||
function turnNumbers(agent: Agent): number[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'turn/start')
|
||||
.map(e => (e.data as { turn: number }).turn)
|
||||
.map(e => e.data.turn)
|
||||
}
|
||||
|
||||
function turnEndNumbers(agent: Agent): number[] {
|
||||
|
||||
@@ -59,26 +59,15 @@ describe('agent/request-error', () => {
|
||||
turn: number
|
||||
step: number
|
||||
failure: LlmFailure
|
||||
priorFailures: readonly LlmFailure[]
|
||||
retryPolicy: ResolvedRetryPolicy | undefined
|
||||
}[] = []
|
||||
const statuses: string[] = []
|
||||
const settledTurns: number[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) statuses.push(status)
|
||||
})
|
||||
ctx.on('agent/settled', (subject, turn) => {
|
||||
if (subject === agent) settledTurns.push(turn)
|
||||
})
|
||||
ctx.on('agent/request-error', async (
|
||||
subject, turn, step, _error, failure, priorFailures, retryPolicy,
|
||||
) => {
|
||||
ctx.on('agent/request-error', async (subject, context) => {
|
||||
expect(subject).toBe(agent)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'step/end',
|
||||
data: { turn, step },
|
||||
})
|
||||
seen.push({ turn, step, failure, priorFailures, retryPolicy })
|
||||
seen.push(context)
|
||||
return { kind: 'retry' }
|
||||
})
|
||||
|
||||
@@ -96,25 +85,17 @@ describe('agent/request-error', () => {
|
||||
code: 'RATE_LIMIT',
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
turn: 1,
|
||||
step: 1,
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
},
|
||||
])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start').map(event => event.data.trigger))
|
||||
.toEqual([
|
||||
{ kind: 'message', source: { kind: 'user' } },
|
||||
{ kind: 'retry' },
|
||||
{ kind: 'retry' },
|
||||
])
|
||||
expect(seen.map(item => item.priorFailures.map(failure => failure.code)))
|
||||
.toEqual([[], ['RATE_LIMIT']])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(seen.map(item => item.retryPolicy)).toEqual([
|
||||
expect.objectContaining({ mode: 'normal' }),
|
||||
expect.objectContaining({ mode: 'normal' }),
|
||||
])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(settledTurns).toEqual([3])
|
||||
})
|
||||
|
||||
it('lets cancellation win over a retry action', async () => {
|
||||
@@ -133,7 +114,7 @@ describe('agent/request-error', () => {
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.find(event => event.type === 'turn/end')).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'aborted' } },
|
||||
data: { reason: { kind: 'aborted', reason: { kind: 'user' } } },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -350,10 +350,6 @@ describe('request stability across the loop', () => {
|
||||
}
|
||||
}([])
|
||||
const ctx = await harness(adapter)
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
||||
if (error instanceof Error) errors.push(error)
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
@@ -362,7 +358,13 @@ describe('request stability across the loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(errors).toContain(failure)
|
||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: {
|
||||
reason: failure instanceof LlmError
|
||||
? { kind: 'error', error: failure.failure }
|
||||
: { kind: 'error', error: { message: failure.message, code: 'UNKNOWN' } },
|
||||
},
|
||||
})
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
@@ -409,19 +411,13 @@ describe('request stability across the loop', () => {
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// A pre-step listener compacts turn 1's history before turn 2's step —
|
||||
// the sanctioned surface rewrite, landing OUTSIDE the step.
|
||||
const preStep = ctx.on('agent/step', () => {
|
||||
preStep()
|
||||
const session = agent.session
|
||||
const nodes = session.surface.nodes
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '[summary of turn 1]' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
|
||||
sourceEventSeqs: [nodes[0]!, nodes[1]!],
|
||||
})
|
||||
const nodes = agent.session.surface.nodes
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '[summary of turn 1]' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compact' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
|
||||
sourceEventSeqs: [nodes[0]!, nodes[1]!],
|
||||
})
|
||||
|
||||
send(agent, 'second')
|
||||
@@ -491,10 +487,6 @@ describe('request stability across the loop', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
||||
if (error instanceof Error) errors.push(error)
|
||||
})
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
// The historical failure mode this design kills: a listener rewriting
|
||||
// request content in place. The freeze turns it into a loud error.
|
||||
@@ -508,8 +500,10 @@ describe('request stability across the loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.message).toMatch(/not extensible|frozen|read only|readonly/i)
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } })
|
||||
if (turnEnd?.type !== 'turn/end' || turnEnd.data.reason.kind !== 'error') throw new Error()
|
||||
expect(turnEnd.data.reason.error.message).toMatch(/not extensible|frozen|read only|readonly/i)
|
||||
})
|
||||
|
||||
it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => {
|
||||
@@ -595,14 +589,18 @@ describe('request stability across the loop', () => {
|
||||
|
||||
adapter.requests.forEach((request, index) => {
|
||||
const stepStart = stepStarts[index]!
|
||||
// Messages: the derivation over the log prefix strictly before this
|
||||
// step's step/start — rebuilt here through a completely fresh Session.
|
||||
const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
|
||||
const firstChunk = events.find(e =>
|
||||
e.type === 'assistant/chunk'
|
||||
&& e.data.turn === stepStart.data.turn
|
||||
&& e.data.step === stepStart.data.step,
|
||||
)!
|
||||
// Messages: the entered batch is logged after step/start, so rebuild the
|
||||
// complete dispatch prefix through a completely fresh Session.
|
||||
const rebuilt = Session.create(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, firstChunk.seq)))
|
||||
expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
|
||||
|
||||
// Header: the latest request/header snapshot up to this step's dispatch
|
||||
// (its header event sits between step/start and the first chunk).
|
||||
const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
|
||||
const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
|
||||
expect(request.model).toBe(header.config.model)
|
||||
expect(request.reasoningEffort).toBe(header.config.reasoningEffort)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionPreparation } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -43,7 +43,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
|
||||
// balanced completed turn is the smallest resumable log and avoids running
|
||||
// the model merely to construct this lifecycle fixture.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const session = ctx.sessions.create(sessionId, { seed })
|
||||
@@ -52,6 +52,18 @@ async function persistSession(sessionId: SessionId): Promise<string> {
|
||||
return root
|
||||
}
|
||||
|
||||
/** Build a detached preparation for lifecycle-race test doubles. */
|
||||
function preparationFromSnapshot(
|
||||
ctx: Context,
|
||||
snapshot: { meta: SessionHeader; events: readonly SessionEvent[] },
|
||||
): SessionPreparation {
|
||||
return SessionPreparation.create(ctx.sessions.prepare(snapshot.meta.id, {
|
||||
seed: structuredClone(snapshot.events) as SessionEvent[],
|
||||
meta: structuredClone(snapshot.meta),
|
||||
seedSource: 'persistence',
|
||||
}))
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
@@ -77,7 +89,7 @@ function throwUnknown(value: unknown): never {
|
||||
}
|
||||
|
||||
describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => {
|
||||
it('resumes a session persisted before messages gained identities', async () => {
|
||||
it('resumes a pre-react-loop session including pre-identity message events', async () => {
|
||||
const sessionId = SessionId('pre-identity-resume')
|
||||
const first = await persistentHarness(new MockAdapter([]))
|
||||
await first.ctx.sessionPersistence.create({
|
||||
@@ -86,7 +98,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
createdAt: 1,
|
||||
})
|
||||
await first.ctx.sessionPersistence.append(sessionId, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{
|
||||
type: 'turn/start', seq: 0, time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
@@ -107,8 +122,19 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{
|
||||
type: 'steering/message',
|
||||
seq: 4,
|
||||
time: 5,
|
||||
data: {
|
||||
turn: 1,
|
||||
content: [{ type: 'text', text: 'old steering' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as unknown as SessionEvent[])
|
||||
await first.ctx.fiber.dispose()
|
||||
|
||||
@@ -120,14 +146,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
expect(handle.agent.session.deriveMessages()).toMatchObject([
|
||||
{ id: `legacy-message:${sessionId}:1`, role: 'user' },
|
||||
{ id: `legacy-message:${sessionId}:3`, role: 'assistant' },
|
||||
{ id: `legacy-message:${sessionId}:4`, role: 'user' },
|
||||
])
|
||||
expect(handle.agent.inbox.nextTurn).toEqual([])
|
||||
expect(handle.agent.inbox.nextStep).toEqual([])
|
||||
|
||||
handle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'new question' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(handle.agent.session.deriveMessages()).toHaveLength(4)
|
||||
expect(handle.agent.session.deriveMessages()).toHaveLength(5)
|
||||
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
@@ -175,11 +204,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')]))
|
||||
const sessionId = SessionId('live-resume-race')
|
||||
const first = (await ctx.agents.create({ sessionId })).agent
|
||||
first.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
first.session.append('turn/start', { turn: 1 })
|
||||
await ctx.sessions.flush(first.session)
|
||||
|
||||
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
|
||||
.rejects.toThrow(/live turn is open/)
|
||||
.rejects.toThrow(/while it is live/)
|
||||
|
||||
first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(first.session)
|
||||
@@ -429,22 +458,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => {
|
||||
it('owner unload aborts a never-settling persistence preparation, releases the identity, and blocks late publication', async () => {
|
||||
const sessionId = SessionId('resume-load-owner-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
let loads = 0
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
const abandoned = preparationFromSnapshot(ctx, snapshot)
|
||||
const latePreparation = Promise.withResolvers<SessionPreparation>()
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
const originalPrepare = ctx.sessionPersistence.prepare.bind(ctx.sessionPersistence)
|
||||
let preparations = 0
|
||||
ctx.sessionPersistence.prepare = (id, signal) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loads += 1
|
||||
if (loads === 1) {
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
preparations += 1
|
||||
if (preparations === 1) {
|
||||
preparationStarted.resolve(undefined)
|
||||
return latePreparation.promise
|
||||
}
|
||||
return Promise.resolve(structuredClone(snapshot))
|
||||
return originalPrepare(id, signal)
|
||||
}
|
||||
|
||||
const published: string[] = []
|
||||
@@ -456,7 +487,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
await loadStarted.promise
|
||||
await preparationStarted.promise
|
||||
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
|
||||
await promptly(owner.dispose())
|
||||
@@ -468,23 +499,24 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
// can be reused before awaiting the public rejection.
|
||||
const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }))
|
||||
await rejection
|
||||
expect(loads).toBe(2)
|
||||
expect(preparations).toBe(2)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
// Settlement of the abandoned backend promise cannot resume the old
|
||||
// transaction or emit a second publication after the retry owns the ids.
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
latePreparation.resolve(abandoned)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(ctx.agents.get(sessionId)).toBe(retry.agent)
|
||||
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
abandoned[Symbol.dispose]()
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
|
||||
it('AgentLoop unload aborts persistence preparation and awaits wrapper settlement', async () => {
|
||||
const sessionId = SessionId('resume-load-factory-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = new Context()
|
||||
@@ -498,19 +530,20 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
|
||||
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
const abandoned = preparationFromSnapshot(ctx, snapshot)
|
||||
const latePreparation = Promise.withResolvers<SessionPreparation>()
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.prepare = (id) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
preparationStarted.resolve(undefined)
|
||||
return latePreparation.promise
|
||||
}
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
await loadStarted.promise
|
||||
await preparationStarted.promise
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
|
||||
await promptly(loopFiber.dispose())
|
||||
await rejection
|
||||
@@ -518,10 +551,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
latePreparation.resolve(abandoned)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual([])
|
||||
abandoned[Symbol.dispose]()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -530,7 +564,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
// materializes the fork (header + seed) on disk.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
@@ -567,7 +601,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('an idle inject() survives persist + resume without a synthetic turn', async () => {
|
||||
it('a pending idle inject() survives persist + resume without a synthetic turn', async () => {
|
||||
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
|
||||
@@ -575,9 +609,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background task 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } }))
|
||||
await a1.whenIdle()
|
||||
await ctx1.fiber.dispose()
|
||||
await ctx1.sessions.flush(a1.session)
|
||||
|
||||
// Lifecycle 2: resume; the injected context is still in the derived history.
|
||||
// Lifecycle 2: resume; the injected context is still pending and becomes
|
||||
// model-visible when the next turn admits it.
|
||||
const adapter2 = new MockAdapter([textResponse('next')])
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(LlmService)
|
||||
@@ -588,10 +623,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const loaded = await ctx2.sessionPersistence.load(SessionId('inject-sess'))
|
||||
expect(loaded.events.some(event => event.type === 'agent/inbox/spliced')).toBe(true)
|
||||
expect(JSON.stringify(loaded.events)).toContain('background task 42 finished')
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
|
||||
expect(JSON.stringify(a2.inbox.nextStep)).toContain('background task 42 finished')
|
||||
a2.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx2, a2)
|
||||
const flat = JSON.stringify(a2.session.deriveMessages())
|
||||
expect(flat).toContain('background task 42 finished')
|
||||
await ctx2.fiber.dispose()
|
||||
await ctx1.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume reloads a persisted session: history + turn numbering continue, no duplicate seqs', async () => {
|
||||
@@ -705,6 +747,24 @@ describe('creation and resume cancellation edges', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects when setup synchronously aborts its caller signal', async () => {
|
||||
const { ctx } = await persistentHarness(new MockAdapter([]))
|
||||
const controller = new AbortController()
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
sessionId: SessionId('setup-synchronous-abort'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: controller.signal,
|
||||
setup() {
|
||||
controller.abort(new Error('setup synchronously cancelled'))
|
||||
},
|
||||
})
|
||||
|
||||
await expect(promptly(creating)).rejects.toThrow('setup synchronously cancelled')
|
||||
expect(ctx.agents.get(SessionId('setup-synchronous-abort'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume with a pre-aborted caller signal rejects out of the load race', async () => {
|
||||
const sessionId = SessionId('resume-pre-aborted')
|
||||
const root = await persistSession(sessionId)
|
||||
@@ -718,19 +778,45 @@ describe('creation and resume cancellation edges', () => {
|
||||
signal: controller.signal,
|
||||
}))).rejects.toThrow('resume abandoned')
|
||||
|
||||
const stringReason = new AbortController()
|
||||
stringReason.abort('resume string reason')
|
||||
await expect(promptly(ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: stringReason.signal,
|
||||
}))).rejects.toThrow(/creation aborted/)
|
||||
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory teardown during a hung resume load rejects with loop-inactive', async () => {
|
||||
it('releases a restored preparation if the loop becomes inactive before setup', async () => {
|
||||
const sessionId = SessionId('resume-loop-inactive-after-prepare')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const loop = ctx.agentLoop as unknown as {
|
||||
ownership: { isActive: () => boolean }
|
||||
}
|
||||
vi.spyOn(loop.ownership, 'isActive').mockReturnValueOnce(false)
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})).rejects.toThrow('agent loop is not active')
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('factory teardown during a hung resume preparation rejects with loop-inactive', async () => {
|
||||
const sessionId = SessionId('resume-loop-teardown')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const gate = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = () => {
|
||||
loadStarted.resolve(undefined)
|
||||
const abandoned = preparationFromSnapshot(ctx, snapshot)
|
||||
const gate = Promise.withResolvers<SessionPreparation>()
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.prepare = () => {
|
||||
preparationStarted.resolve(undefined)
|
||||
return gate.promise
|
||||
}
|
||||
|
||||
@@ -738,27 +824,28 @@ describe('creation and resume cancellation edges', () => {
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
await loadStarted.promise
|
||||
// Resolve the load only after teardown began: the post-load ownership
|
||||
await preparationStarted.promise
|
||||
// Resolve the preparation only after teardown began: the post-prepare ownership
|
||||
// check, not the abort race, must reject the wrapper.
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow()
|
||||
const disposal = ctx.fiber.dispose()
|
||||
gate.resolve(structuredClone(snapshot))
|
||||
gate.resolve(abandoned)
|
||||
await rejection
|
||||
await disposal
|
||||
abandoned[Symbol.dispose]()
|
||||
})
|
||||
})
|
||||
|
||||
describe('configured-start failure edges', () => {
|
||||
it('a non-Error mid-load abort reason is wrapped for the resume caller', async () => {
|
||||
it('a non-Error mid-prepare abort reason is wrapped for the resume caller', async () => {
|
||||
const sessionId = SessionId('resume-string-mid-abort')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const gate = Promise.withResolvers<never>()
|
||||
gate.promise.catch(() => undefined)
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = () => {
|
||||
loadStarted.resolve(undefined)
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.prepare = () => {
|
||||
preparationStarted.resolve(undefined)
|
||||
return gate.promise
|
||||
}
|
||||
const controller = new AbortController()
|
||||
@@ -768,7 +855,7 @@ describe('configured-start failure edges', () => {
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
await loadStarted.promise
|
||||
await preparationStarted.promise
|
||||
controller.abort('operator string reason')
|
||||
|
||||
await expect(promptly(resuming)).rejects.toThrow(/creation aborted/)
|
||||
@@ -783,7 +870,7 @@ describe('configured-start failure edges', () => {
|
||||
// The artifact exists (list reports it) but its load fails: this is
|
||||
// corruption, not first creation — the failure must be reported, and no
|
||||
// fresh same-id session may shadow the broken one.
|
||||
ctx.sessionPersistence.load = () => Promise.reject(new Error('artifact corrupt'))
|
||||
ctx.sessionPersistence.prepare = () => Promise.reject(new Error('artifact corrupt'))
|
||||
|
||||
const configured = new Context()
|
||||
await configured.plugin(LlmService)
|
||||
@@ -793,7 +880,7 @@ describe('configured-start failure edges', () => {
|
||||
await configured.plugin(AgentRegistry)
|
||||
await configured.plugin(SessionPersistenceJsonl, { root })
|
||||
configured.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
|
||||
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal)
|
||||
const configFailures: unknown[] = []
|
||||
configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) })
|
||||
const configWarnings: string[] = []
|
||||
@@ -822,9 +909,9 @@ describe('configured-start failure edges', () => {
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([]))
|
||||
const gate = Promise.withResolvers<never>()
|
||||
gate.promise.catch(() => undefined)
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = () => {
|
||||
loadStarted.resolve(undefined)
|
||||
const preparationStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.prepare = () => {
|
||||
preparationStarted.resolve(undefined)
|
||||
return gate.promise
|
||||
}
|
||||
const failures: unknown[] = []
|
||||
@@ -838,12 +925,12 @@ describe('configured-start failure edges', () => {
|
||||
await configured.plugin(AgentRegistry)
|
||||
await configured.plugin(SessionPersistenceJsonl, { root })
|
||||
configured.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
configured.sessionPersistence.load = id => ctx.sessionPersistence.load(id)
|
||||
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal)
|
||||
configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
|
||||
const loop = await configured.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }],
|
||||
})
|
||||
await loadStarted.promise
|
||||
await preparationStarted.promise
|
||||
const disposal = loop.dispose()
|
||||
gate.reject(new Error('late backend failure'))
|
||||
await disposal
|
||||
|
||||
45
packages/core/agent-loop/tests/runtime-context.spec.ts
Normal file
45
packages/core/agent-loop/tests/runtime-context.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { RuntimeContextProjection } from '../src/runtime-context.ts'
|
||||
|
||||
const SOURCE = '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
function contextMessage(text: string) {
|
||||
return createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: SOURCE },
|
||||
})
|
||||
}
|
||||
|
||||
describe('RuntimeContextProjection', () => {
|
||||
it('restores the latest visible owned snapshot and ignores other sessions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('runtime-context-replay'))
|
||||
const retained = session.append('user/message', contextMessage('retained'), { surfaceOp: 'append' })
|
||||
const shadowed = session.append('user/message', contextMessage('shadowed'), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test-compaction' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: shadowed.seq, end: shadowed.seq },
|
||||
sourceEventSeqs: [shadowed.seq],
|
||||
})
|
||||
|
||||
const projection = new RuntimeContextProjection(ctx, session)
|
||||
expect(session.surface.nodes).toContain(retained.seq)
|
||||
expect(projection.project('retained', [])).toBeUndefined()
|
||||
expect(projection.project('next', [{ name: 'sandbox:policy', text: 'policy' }])?.source).toEqual({
|
||||
kind: 'plugin',
|
||||
plugin: SOURCE,
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'sandbox:policy', text: 'policy' }],
|
||||
})
|
||||
|
||||
const other = ctx.sessions.create(SessionId('runtime-context-other'))
|
||||
other.append('user/message', contextMessage('other'), { surfaceOp: 'append' })
|
||||
expect(projection.project('retained', [])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -147,7 +147,7 @@ describe('agent scope lifecycle', () => {
|
||||
expect(agent.ctx.agent).toBe(agent)
|
||||
// The root accessor default: a plain context answers undefined, not a throw.
|
||||
expect(ctx.agent).toBeUndefined()
|
||||
await ctx.agents.get(SessionId('a1'))?.whenIdle()
|
||||
await agent.whenIdle()
|
||||
})
|
||||
|
||||
it('records agents created through an agent context as non-root runtime children', async () => {
|
||||
@@ -1074,7 +1074,7 @@ describe('agent scope lifecycle', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(reentered).toBe(true)
|
||||
|
||||
// Idle again: the reentrant admission was already claimed and settled (its
|
||||
// Idle again: the reentrant batch was already claimed and settled (its
|
||||
// prompt was blocked by nothing, so it ran) — arm a SECOND reentry that
|
||||
// fires from the disposal cancel's idle transition itself.
|
||||
reentered = false
|
||||
|
||||
@@ -518,10 +518,10 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
|
||||
it('stops replenishing after abort, commits started results, and parks accepted additional contexts', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))),
|
||||
textResponse('should never be requested'),
|
||||
textResponse('after wake'),
|
||||
])
|
||||
const ctx = await harness(adapter, 2)
|
||||
const gated = gatedParallelTool('p')
|
||||
@@ -566,9 +566,23 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
const settled = events(agent).filter(e => e.type === 'tool/result'
|
||||
|| (e.type === 'user/message' && e.data.source.kind === 'plugin'))
|
||||
expect(settled.map(e => e.type))
|
||||
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'user/message', 'user/message'])
|
||||
expect(settled.filter(e => e.type === 'user/message')
|
||||
.map(e => (e.data.content[0] as { text: string }).text))
|
||||
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result'])
|
||||
expect(agent.inbox.nextStep.map(message => message.content[0]))
|
||||
.toEqual([
|
||||
{ type: 'text', text: 'ctx-c1' },
|
||||
{ type: 'text', text: 'ctx-c2' },
|
||||
])
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'wake' }], source: { kind: 'user' } }))
|
||||
await idle
|
||||
|
||||
expect(events(agent).flatMap(e =>
|
||||
e.type === 'user/message'
|
||||
&& e.data.source.kind === 'plugin'
|
||||
&& e.data.content[0]?.type === 'text'
|
||||
? [e.data.content[0].text]
|
||||
: []))
|
||||
.toEqual(['ctx-c1', 'ctx-c2'])
|
||||
})
|
||||
|
||||
@@ -648,10 +662,6 @@ describe('tool-call scheduler: failure quiescence', () => {
|
||||
? new Promise((_resolve, reject) => { rejectFirst = reject })
|
||||
: dispatch(exec).then(() => { throw drainedError })
|
||||
const agent = ctx.agentLoop.create(SessionId('scheduler-failure'), { provider: 'mock', model: 'mock' })
|
||||
const errors: unknown[] = []
|
||||
ctx.on('agent/error', (subject, _turn, _step, error) => {
|
||||
if (subject === agent) errors.push(error)
|
||||
})
|
||||
let idle = false
|
||||
const idlePromise = waitForIdle(ctx, agent).then(() => { idle = true })
|
||||
|
||||
@@ -664,15 +674,16 @@ describe('tool-call scheduler: failure quiescence', () => {
|
||||
|
||||
const startedBeforeDrain = [...gated.started]
|
||||
const idleBeforeDrain = idle
|
||||
const errorsBeforeDrain = [...errors]
|
||||
const turnEndBeforeDrain = events(agent).find(event => event.type === 'turn/end')
|
||||
for (const id of gated.pending()) gated.release(id)
|
||||
await idlePromise
|
||||
|
||||
expect(startedBeforeDrain).toEqual(['2'])
|
||||
expect(idleBeforeDrain).toBe(false)
|
||||
expect(errorsBeforeDrain).toEqual([])
|
||||
expect(turnEndBeforeDrain).toBeUndefined()
|
||||
expect(gated.pending()).toEqual([])
|
||||
expect(errors).toEqual([schedulerError])
|
||||
expect(errors[0]).toBe(schedulerError)
|
||||
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||
data: { reason: { kind: 'error', error: { message: schedulerError.message, code: 'UNKNOWN' } } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,24 +93,18 @@ describe('loop-level canonical tool order', () => {
|
||||
expect(Object.isFrozen(adapter.requests[0])).toBe(true)
|
||||
})
|
||||
|
||||
it('fails the turn — no model request — when toolOrder names an unregistered tool', async () => {
|
||||
// Unknown tool order fails before step or request creation and returns the agent to idle.
|
||||
it('closes a no-step turn when toolOrder names an unregistered tool', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never sent')])
|
||||
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
|
||||
registerNamed(ctx, 'alpha')
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => {
|
||||
if (error instanceof Error) errors.push(error)
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])
|
||||
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
|
||||
const end = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
|
||||
// The turn is balanced (turn/start → turn/end) with no step events inside.
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(true)
|
||||
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
|
||||
expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
|
||||
expect(agent.session.events.some(e => e.type === 'step/end')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { type Agent, type InboxItem } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function prompt(agent: Agent, text: string): void {
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
}
|
||||
|
||||
function itemText(item: InboxItem): string {
|
||||
return item.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
}
|
||||
|
||||
interface InboxRecording {
|
||||
readonly events: string[]
|
||||
readonly enqueued: InboxItem['id'][]
|
||||
readonly dequeued: InboxItem['id'][]
|
||||
readonly discarded: InboxItem['id'][]
|
||||
}
|
||||
|
||||
/** Record the complete inbox lifecycle of one agent for order and identity assertions. */
|
||||
function recordInbox(ctx: Context): InboxRecording {
|
||||
const events: string[] = []
|
||||
const enqueued: InboxItem['id'][] = []
|
||||
const dequeued: InboxItem['id'][] = []
|
||||
const discarded: InboxItem['id'][] = []
|
||||
ctx.on('agent/inbox/enqueue', (_agent, item) => {
|
||||
events.push(`enqueue:${item.placement}:${itemText(item)}`)
|
||||
enqueued.push(item.id)
|
||||
})
|
||||
ctx.on('agent/inbox/dequeue', (_agent, item) => {
|
||||
events.push(`dequeue:${itemText(item)}`)
|
||||
dequeued.push(item.id)
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (_agent, items) => {
|
||||
events.push(`discard:${items.map(itemText).join(',')}`)
|
||||
discarded.push(...items.map(item => item.id))
|
||||
})
|
||||
return { events, enqueued, dequeued, discarded }
|
||||
}
|
||||
|
||||
/** Text of every ordinary prompt the log admitted, in durable order. */
|
||||
function promptTexts(agent: Agent): string[] {
|
||||
return agent.session.events.flatMap(event => event.type === 'user/message'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
: [])
|
||||
}
|
||||
|
||||
describe('idle turn admission reservation', () => {
|
||||
it('holds later waking prompts in the FIFO until release', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const inbox = recordInbox(ctx)
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
expect(release).toBeDefined()
|
||||
|
||||
prompt(agent, 'first prompt')
|
||||
prompt(agent, 'second prompt')
|
||||
expect(agent.acceptsNextStep).toBe(false)
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
expect(inbox.events).toEqual([
|
||||
'enqueue:queued:first prompt',
|
||||
'enqueue:queued:second prompt',
|
||||
])
|
||||
|
||||
release?.()
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(promptTexts(agent)).toEqual(['first prompt', 'second prompt'])
|
||||
expect(agent.session.events.flatMap(event =>
|
||||
event.type === 'turn/start' ? [event.data.turn] : [])).toEqual([1, 2])
|
||||
expect(inbox.events).toEqual([
|
||||
'enqueue:queued:first prompt',
|
||||
'enqueue:queued:second prompt',
|
||||
'dequeue:first prompt',
|
||||
'dequeue:second prompt',
|
||||
])
|
||||
expect(inbox.dequeued).toEqual(inbox.enqueued)
|
||||
expect(inbox.discarded).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses acquisition when an accepted waking prompt still owns the next turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
prompt(agent, 'accepted first')
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(agent.reserveTurnAdmission()).toBeUndefined()
|
||||
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses acquisition while a turn is running', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const reserved: unknown[] = []
|
||||
ctx.on('agent/step', () => {
|
||||
reserved.push(agent.reserveTurnAdmission())
|
||||
})
|
||||
|
||||
prompt(agent, 'running')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(reserved).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('refuses a second reservation and releases idempotently', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
expect(agent.reserveTurnAdmission()).toBeUndefined()
|
||||
prompt(agent, 'queued behind the reservation')
|
||||
|
||||
release?.()
|
||||
release?.()
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(promptTexts(agent)).toEqual(['queued behind the reservation'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const second = agent.reserveTurnAdmission()
|
||||
expect(second).toBeDefined()
|
||||
second?.()
|
||||
})
|
||||
|
||||
it('ignores a stale release once a later reservation owns the boundary', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const stale = agent.reserveTurnAdmission()
|
||||
stale?.()
|
||||
const live = agent.reserveTurnAdmission()
|
||||
prompt(agent, 'held by the live reservation')
|
||||
stale?.()
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
live?.()
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('acquires beside quiet queued work and leaves it queued', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send(createUserMessage({
|
||||
content: [{ type: 'text', text: 'quiet' }],
|
||||
source: { kind: 'user' },
|
||||
}), {
|
||||
target: 'next-turn',
|
||||
wakeup: false,
|
||||
})
|
||||
const release = agent.reserveTurnAdmission()
|
||||
expect(release).toBeDefined()
|
||||
|
||||
release?.()
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('makes whenIdle() wait for release without spinning on a settled promise', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const machine = agent as Agent & { done: Promise<void> }
|
||||
let backing = machine.done
|
||||
let reads = 0
|
||||
Object.defineProperty(agent, 'done', {
|
||||
configurable: true,
|
||||
get(): Promise<void> {
|
||||
reads += 1
|
||||
return backing
|
||||
},
|
||||
set(value: Promise<void>) {
|
||||
backing = value
|
||||
},
|
||||
})
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
prompt(agent, 'waiting for the reservation')
|
||||
let settled = false
|
||||
const idle = agent.whenIdle().then(() => { settled = true })
|
||||
for (let tick = 0; tick < 5; tick += 1) {
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 1) })
|
||||
}
|
||||
|
||||
expect(settled).toBe(false)
|
||||
expect(reads).toBeLessThanOrEqual(2)
|
||||
|
||||
release?.()
|
||||
await idle
|
||||
expect(settled).toBe(true)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolves whenIdle() after release with nothing queued', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
let settled = false
|
||||
const idle = agent.whenIdle().then(() => { settled = true })
|
||||
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
|
||||
expect(settled).toBe(false)
|
||||
|
||||
release?.()
|
||||
await idle
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('lets cancellation discard held prompts and keeps the boundary quiet', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const inbox = recordInbox(ctx)
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
prompt(agent, 'discarded while held')
|
||||
agent.cancel({ kind: 'user' })
|
||||
|
||||
expect(inbox.events).toEqual([
|
||||
'enqueue:queued:discarded while held',
|
||||
'discard:discarded while held',
|
||||
])
|
||||
expect(inbox.discarded).toEqual(inbox.enqueued)
|
||||
expect(inbox.dequeued).toEqual([])
|
||||
|
||||
release?.()
|
||||
await agent.whenIdle()
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disposes the agent without waiting for the reservation to be released', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('a1'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const { agent } = handle
|
||||
|
||||
const release = agent.reserveTurnAdmission()
|
||||
prompt(agent, 'discarded by disposal')
|
||||
await handle.dispose()
|
||||
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
release?.()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user