refactor agent pre-step inbox lifecycle
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]).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]).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()
|
||||
|
||||
@@ -50,7 +50,37 @@ describe('Agent', () => {
|
||||
.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[] = []
|
||||
ctx.on('agent/inbox/inserted', (subject, event) => {
|
||||
if (subject === agent) inserted.push(event)
|
||||
})
|
||||
ctx.on('agent/inbox/claimed', (subject, event) => {
|
||||
if (subject === agent) 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('next-step', 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 }])
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
|
||||
@@ -668,9 +668,8 @@ describe('Agent.cancel()', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
'prompt-submit',
|
||||
'pre-step',
|
||||
'system-prompt',
|
||||
'step',
|
||||
'request',
|
||||
'stopping',
|
||||
'tool',
|
||||
@@ -690,8 +689,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()
|
||||
})
|
||||
@@ -705,11 +704,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)
|
||||
@@ -741,7 +735,7 @@ describe('Agent.cancel()', () => {
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
if (stage === 'prompt-submit') {
|
||||
if (stage === 'pre-step') {
|
||||
expect(turnEnd).toBeUndefined()
|
||||
} else {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
|
||||
@@ -67,30 +67,30 @@ describe('addressable inbox operations', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' })
|
||||
const admission = Promise.withResolvers<undefined>()
|
||||
const preStep = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.on('agent/prompt-submit', async (_subject, messages, _signal, next) => {
|
||||
ctx.on('agent/pre-step', async (_subject, messages, _signal, next) => {
|
||||
if (messages[0]?.content[0]?.type === 'text' && messages[0].content[0].text === 'first') {
|
||||
admission.resolve(undefined)
|
||||
preStep.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await admission.promise
|
||||
await preStep.promise
|
||||
send(agent, 'remove me')
|
||||
send(agent, 'edit me')
|
||||
const pending = agent.inbox.nextTurn
|
||||
expect(pending.map(inboxText)).toEqual(['first', 'remove me', 'edit me'])
|
||||
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
|
||||
|
||||
const remove = pending[1]!
|
||||
const edit = pending[2]!
|
||||
expect(agent.inbox.splice('next-turn', 2, 1, [freezeMessage({
|
||||
const remove = pending[0]!
|
||||
const edit = pending[1]!
|
||||
expect(agent.inbox.splice('next-turn', 1, 1, [freezeMessage({
|
||||
...edit,
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})])).toEqual([edit])
|
||||
expect(agent.inbox.splice('next-turn', 1, 1, [])).toEqual([remove])
|
||||
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([remove])
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
release.resolve(undefined)
|
||||
@@ -316,11 +316,19 @@ describe('abort during tool execution ends the turn', () => {
|
||||
|
||||
send(agent, 'leave an unmatched historical call')
|
||||
await waitForIdle(ctx, agent)
|
||||
const disposeInjection = ctx.on('agent/step', (subject, turn) => {
|
||||
if (subject === agent && turn === 2) {
|
||||
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()
|
||||
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } }))
|
||||
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)
|
||||
@@ -903,8 +911,7 @@ describe('turn and step boundary recovery', () => {
|
||||
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 without opening a turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
let agent!: Agent
|
||||
@@ -913,8 +920,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')
|
||||
@@ -928,12 +935,9 @@ 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: 'aborted', reason: { kind: 'disposed' } })
|
||||
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(true)
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(errorEmits).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -1224,9 +1228,8 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
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 prevents the turn from opening', { 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))
|
||||
@@ -1241,8 +1244,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
|
||||
@@ -1261,23 +1265,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 before any LLM call.
|
||||
// The post-listener cancellation check catches disposal before any turn 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: 'aborted', reason: { kind: 'disposed' } })
|
||||
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
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([])
|
||||
})
|
||||
|
||||
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 prevents the turn from opening', { 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))
|
||||
@@ -1292,8 +1290,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
|
||||
@@ -1314,14 +1313,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', reason: { kind: 'user' } })
|
||||
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
|
||||
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
expect(e.some(x => x.type === 'step/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
expect(reasons).toEqual([])
|
||||
})
|
||||
|
||||
it('disposal during assembly does not leak an LLM call or append assistant/chunk', { timeout: 15000 }, async () => {
|
||||
|
||||
@@ -11,7 +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 PromptDecision,
|
||||
type PreStepDecision,
|
||||
type SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
@@ -19,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
|
||||
@@ -58,14 +58,14 @@ 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, messages, _signal, next) => {
|
||||
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()
|
||||
})
|
||||
@@ -78,15 +78,43 @@ 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/prompt-submit', async (subject, messages) => {
|
||||
if (subject !== agent) return { kind: 'allow', messages }
|
||||
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)
|
||||
@@ -115,7 +143,7 @@ describe('agent/prompt-submit', () => {
|
||||
expect(() => {
|
||||
if (input.source.kind === 'plugin') input.source.plugin = 'caller mutation'
|
||||
}).toThrow(TypeError)
|
||||
decision.resolve({ kind: 'allow', messages: [input] })
|
||||
decision.resolve({ kind: 'enter', messages: [input] })
|
||||
await idle
|
||||
|
||||
expect(observed).toHaveLength(1)
|
||||
@@ -128,14 +156,14 @@ 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 (_agent, messages): Promise<PromptDecision> =>
|
||||
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
kind: 'enter',
|
||||
messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }],
|
||||
}))
|
||||
|
||||
@@ -149,14 +177,14 @@ 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 (_agent, messages): Promise<PromptDecision> =>
|
||||
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
kind: 'enter',
|
||||
messages: [...messages, createUserMessage({
|
||||
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
@@ -176,43 +204,12 @@ 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')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages): Promise<PromptDecision> =>
|
||||
({
|
||||
kind: 'allow',
|
||||
messages: [{
|
||||
...messages[0]!,
|
||||
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
|
||||
}, createUserMessage({
|
||||
content: [{ type: 'text', text: 'injected ctx' }], 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())
|
||||
})
|
||||
|
||||
send(agent, 'ORIGINAL prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(preStepDerived).toBeDefined()
|
||||
expect(preStepDerived).toContain('REWRITTEN prompt')
|
||||
expect(preStepDerived).toContain('injected ctx')
|
||||
expect(preStepDerived).not.toContain('ORIGINAL prompt')
|
||||
})
|
||||
|
||||
it('block drops the claimed prompt before any turn or model call', async () => {
|
||||
it('reject drops the claimed prompt before any turn 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', discardClaimed: true }))
|
||||
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) })
|
||||
@@ -230,44 +227,24 @@ describe('agent/prompt-submit', () => {
|
||||
expect(reasons).toEqual([])
|
||||
})
|
||||
|
||||
it('block can retain the claimed prompt without opening a turn', async () => {
|
||||
const adapter = new MockAdapter([])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('retained-claim'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> => ({
|
||||
kind: 'block',
|
||||
reason: 'try later',
|
||||
discardClaimed: false,
|
||||
}))
|
||||
|
||||
send(agent, 'retained')
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(agent.inbox.nextTurn.map(message => message.content[0]))
|
||||
.toEqual([{ type: 'text', text: 'retained' }])
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(adapter.requests).toEqual([])
|
||||
})
|
||||
|
||||
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 decision = Promise.withResolvers<PreStepDecision>()
|
||||
let claimed: UserMessage[] = []
|
||||
let firstAdmission = true
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages) => {
|
||||
if (!firstAdmission) return { kind: 'allow', messages }
|
||||
firstAdmission = false
|
||||
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
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'admitted prompt')
|
||||
send(agent, 'entered prompt')
|
||||
await entered.promise
|
||||
expect(agent.status).toBe('running')
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
@@ -276,15 +253,15 @@ describe('agent/prompt-submit', () => {
|
||||
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(agent.inbox.nextStep.map(message => message.content[0]))
|
||||
.toEqual([
|
||||
{ type: 'text', text: 'attached context' },
|
||||
{ type: 'text', text: 'admission steering' },
|
||||
{ type: 'text', text: 'pre-step steering' },
|
||||
])
|
||||
|
||||
decision.resolve({ kind: 'allow', messages: claimed })
|
||||
decision.resolve({ kind: 'enter', messages: claimed })
|
||||
await idle
|
||||
expect(agent.inbox.hasPending).toBe(false)
|
||||
|
||||
@@ -297,27 +274,27 @@ describe('agent/prompt-submit', () => {
|
||||
'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 === 'user/message' && staged[3].data.content)
|
||||
.toEqual([{ type: 'text', text: 'admission steering' }])
|
||||
.toEqual([{ type: 'text', text: 'pre-step steering' }])
|
||||
const firstRequest = JSON.stringify(adapter.requests[0]?.messages)
|
||||
expect(firstRequest).toContain('admitted prompt')
|
||||
expect(firstRequest).toContain('entered prompt')
|
||||
expect(firstRequest).not.toContain('attached context')
|
||||
expect(firstRequest).not.toContain('admission steering')
|
||||
expect(firstRequest).not.toContain('pre-step steering')
|
||||
const nextRequest = JSON.stringify(adapter.requests[1]?.messages)
|
||||
expect(nextRequest).toContain('attached context')
|
||||
expect(nextRequest).toContain('admission steering')
|
||||
expect(nextRequest).toContain('pre-step steering')
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
@@ -330,7 +307,7 @@ describe('agent/prompt-submit', () => {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'staged steering' }], source: { kind: 'user' } }))
|
||||
decision.resolve({ kind: 'block', reason: 'policy', discardClaimed: true })
|
||||
decision.resolve({ kind: 'reject' })
|
||||
await blockedIdle
|
||||
|
||||
expect(agent.inbox.nextStep.map(message => message.content[0]))
|
||||
@@ -357,24 +334,24 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
|
||||
})
|
||||
|
||||
it('preserves later queued work when an admission is blocked', async () => {
|
||||
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, messages, _signal, next) => {
|
||||
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => {
|
||||
const decision = await next()
|
||||
return messages.some(message =>
|
||||
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))
|
||||
? { kind: 'block', reason: 'policy', discardClaimed: true }
|
||||
? { kind: 'reject' as const }
|
||||
: decision
|
||||
})
|
||||
ctx.on('agent/prompt-submit', async (subject, messages, _signal, next) => {
|
||||
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({
|
||||
@@ -414,13 +391,13 @@ describe('agent/prompt-submit', () => {
|
||||
expect(request).not.toContain('blocked prompt')
|
||||
})
|
||||
|
||||
it('preserves context-only injection staged after admission began', async () => {
|
||||
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>()
|
||||
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
|
||||
})
|
||||
@@ -432,7 +409,7 @@ describe('agent/prompt-submit', () => {
|
||||
content: [{ type: 'text', text: 'independent context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}))
|
||||
decision.resolve({ kind: 'block', reason: 'policy', discardClaimed: true })
|
||||
decision.resolve({ kind: 'reject' })
|
||||
await idle
|
||||
|
||||
const log = events(agent)
|
||||
@@ -452,7 +429,7 @@ describe('agent/prompt-submit', () => {
|
||||
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',
|
||||
})
|
||||
@@ -476,11 +453,11 @@ describe('agent/prompt-submit', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, messages, _signal, next): Promise<PromptDecision> => {
|
||||
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: 'block', reason: 'policy: no secrets', discardClaimed: true }
|
||||
? { kind: 'reject' }
|
||||
: next()
|
||||
})
|
||||
|
||||
@@ -506,15 +483,15 @@ describe('agent/prompt-submit', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('secret')
|
||||
})
|
||||
|
||||
it('a throwing prompt-submit listener reports the driver error and retains adjacent work', 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 (_agent, messages) => {
|
||||
ctx.on('agent/pre-step', async (_agent, messages) => {
|
||||
if (!threw) { threw = true; throw new Error('prompt hook broke') }
|
||||
return { kind: 'allow' as const, messages }
|
||||
return { kind: 'enter' as const, messages }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -538,7 +515,8 @@ describe('agent/prompt-submit', () => {
|
||||
expect(reasons).toEqual([])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.inbox.nextTurn).toHaveLength(2)
|
||||
expect(agent.inbox.nextTurn.map(message => message.content[0]))
|
||||
.toEqual([{ type: 'text', text: 'second' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -716,16 +694,12 @@ 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, messages, _signal, next): Promise<PromptDecision> => {
|
||||
// 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: 'block',
|
||||
reason: 'destructive prompt blocked',
|
||||
discardClaimed: true,
|
||||
}
|
||||
return { kind: 'reject' }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
@@ -800,7 +774,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)
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('agent loop', () => {
|
||||
expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
|
||||
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
// Durable inbox receipt and admission bracket the turn-owned transcript.
|
||||
// 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')
|
||||
@@ -313,7 +313,7 @@ describe('agent loop', () => {
|
||||
const steering = agent.session.events.find(e =>
|
||||
e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans'))
|
||||
expect(steering).toBeDefined()
|
||||
// Steering is admitted before the second step's request derives history.
|
||||
// Steering enters history before the second step's request derives it.
|
||||
const steeringSeq = steering!.seq
|
||||
const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1]
|
||||
expect(secondStepStart).toBeDefined()
|
||||
@@ -348,30 +348,30 @@ describe('agent loop', () => {
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('second idle steer')
|
||||
})
|
||||
|
||||
it('stops after a throwing step observer and retains steering until a later wakeup', 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')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(0)
|
||||
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.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
|
||||
})
|
||||
@@ -603,7 +603,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'),
|
||||
@@ -616,8 +616,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')
|
||||
@@ -630,33 +631,33 @@ describe('agent loop', () => {
|
||||
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
|
||||
})
|
||||
|
||||
it('agent/step fires after its step boundary opens and before the request', async () => {
|
||||
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 boundaryOpen = false
|
||||
ctx.on('agent/step', (subject) => {
|
||||
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)
|
||||
|
||||
expect(boundaryOpen).toBe(true)
|
||||
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[] = []
|
||||
@@ -666,14 +667,11 @@ describe('agent loop', () => {
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
// The first turn failed at step 1 before a model call.
|
||||
// The first proposal failed before opening a turn or 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' })
|
||||
// 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(false)
|
||||
expect(agent.session.events.some(event => event.type === 'turn/end')).toBe(false)
|
||||
|
||||
// The loop survived: a second prompt runs a normal completed turn.
|
||||
send(agent, 'second')
|
||||
|
||||
@@ -532,7 +532,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
|
||||
|
||||
@@ -1066,7 +1066,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
|
||||
|
||||
Reference in New Issue
Block a user