fix(agent-loop): open turns before pre-step
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
|
||||
README.md: 2029b9424975618beccf35c42e42d0bacb6743c0
|
||||
README.zh.md: e83739635539ce5ba6f070113c164d914ca4167f
|
||||
README.md: ec1948506bbaf7a3416c2031fb472a9b513b500f
|
||||
README.zh.md: 559cfab2d43d94d01567326e9292cbf4431eda02
|
||||
|
||||
@@ -55,7 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and
|
||||
|
||||
The concrete `ReactLoopAgent`, its inbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver.
|
||||
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed and closes the turn without a step; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver.
|
||||
|
||||
Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection.
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ interface Config {
|
||||
|
||||
实体 `ReactLoopAgent`、其 inbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
|
||||
|
||||
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`followup()` 追加到 `next-turn` FIFO 并唤醒驱动器,`steer()` 追加到 `next-step` inbox 并唤醒驱动器,`inject()` 则追加到同一个 `next-step` inbox,但不唤醒驱动器。在轮次边界,驱动器会原子领取待处理的 next-step 输入和一条排队提示词;在步骤之间则只领取 next-step 输入。领取通过纯删除 splice 移除批次,并针对每条消息发出 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 返回 reject,或返回拟进入步骤的完整消息。reject 后已领取批次保持已删除;领取后插入的输入仍等待后续处理,而空闲注入会一直等待,直到 follow-up 或 steering 唤醒驱动器。
|
||||
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`followup()` 追加到 `next-turn` FIFO 并唤醒驱动器,`steer()` 追加到 `next-step` inbox 并唤醒驱动器,`inject()` 则追加到同一个 `next-step` inbox,但不唤醒驱动器。在轮次边界,驱动器会先打开持久轮次,再原子领取待处理的 next-step 输入和一条排队提示词;在步骤之间则只领取 next-step 输入。领取通过纯删除 splice 移除批次,并针对每条消息发出 `agent/inbox/claimed { message, turn }`。随后 `agent/pre-step` 返回 reject,或返回拟进入步骤的完整消息。reject 后已领取批次保持已删除,并关闭不含步骤的轮次;领取后插入的输入仍等待后续处理,而空闲注入会一直等待,直到 follow-up 或 steering 唤醒驱动器。
|
||||
|
||||
每次 inbox 变更都会先发布一条规范化的 `agent/inbox/spliced` 事件,再修改实时投影。因此,插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,随后由循环发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }`。`MessageId` 在两个待处理列表之间保持唯一,同步持久事件观察方可以从 splice 前投影重建被移除的值。
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ export class ReactLoopAgent implements Agent {
|
||||
return decision.kind === 'reject' ? decision : { ...decision, assembly }
|
||||
}
|
||||
|
||||
/** Claimed input stays unowned until `turn/start` commits. */
|
||||
/** Open one turn before claiming its first proposed step. */
|
||||
private async turn(): Promise<boolean> {
|
||||
if (this.phase.kind === 'idle' || this.phase.kind === 'maintenance') {
|
||||
this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
|
||||
@@ -230,30 +230,32 @@ export class ReactLoopAgent implements Agent {
|
||||
const phase = { kind: 'running' as const, abort, turn: lastTurn, step: 0 }
|
||||
this.setPhase(phase)
|
||||
signal.throwIfAborted()
|
||||
let decision: PreparedStep
|
||||
try {
|
||||
decision = await this.preStep('next-turn', { turn: phase.turn + 1, step: 1 })
|
||||
if (decision.kind === 'reject') return false
|
||||
// An empty admitted batch (claimed input removed before the wake, or no
|
||||
// runtime-context change) parks the driver instead of opening a turn and
|
||||
// spending a model call on nothing.
|
||||
if (decision.messages.length === 0) return false
|
||||
signal.throwIfAborted()
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw error
|
||||
this.throwError(error)
|
||||
}
|
||||
const turn = ++phase.turn
|
||||
const turn = phase.turn + 1
|
||||
try {
|
||||
this.session.append('turn/start', { turn })
|
||||
} catch (error: unknown) {
|
||||
this.throwError(error)
|
||||
}
|
||||
phase.turn = turn
|
||||
let turnEnds: TurnEndReason | null = null
|
||||
let target: InboxTarget = 'next-turn'
|
||||
try {
|
||||
while (true) {
|
||||
signal.throwIfAborted()
|
||||
const step = phase.step + 1
|
||||
const decision = await this.preStep(target, { turn, step })
|
||||
if (decision.kind === 'reject') {
|
||||
turnEnds = { kind: 'blocked' }
|
||||
return false
|
||||
}
|
||||
if (turnEnds && decision.messages.length === 0) break
|
||||
// A removed waking message or an enter decision rewritten to empty
|
||||
// still owns the initial turn boundary, but it spends no model call.
|
||||
if (phase.step === 0 && decision.messages.length === 0) {
|
||||
turnEnds = { kind: 'completed' }
|
||||
return false
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
this.session.append('step/start', { turn, step })
|
||||
phase.step = step
|
||||
try {
|
||||
@@ -275,13 +277,7 @@ export class ReactLoopAgent implements Agent {
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
if (turnEnds && this.inbox.nextStep.length === 0) break
|
||||
decision = await this.preStep('next-step', { turn, step: phase.step + 1 })
|
||||
if (decision.kind === 'reject') {
|
||||
turnEnds = { kind: 'blocked' }
|
||||
return false
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (decision.messages.length === 0 && turnEnds) break
|
||||
target = 'next-step'
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) {
|
||||
|
||||
@@ -56,11 +56,18 @@ describe('Agent', () => {
|
||||
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) claimed.push(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)
|
||||
@@ -78,6 +85,7 @@ describe('Agent', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -361,33 +361,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', reason: { kind: 'user' } }])
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -487,8 +460,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) => {
|
||||
@@ -735,11 +708,8 @@ describe('Agent.cancel()', () => {
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
if (stage === 'pre-step' || stage === 'system-prompt') {
|
||||
expect(turnEnd).toBeUndefined()
|
||||
} else {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
}
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason)
|
||||
.toEqual({ kind: 'aborted', reason: { kind: 'user' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
it('parks an empty admitted batch instead of opening a turn', 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' })
|
||||
@@ -197,7 +197,11 @@ describe('abort during tool execution ends the turn', () => {
|
||||
agent.inbox.remove(agent.inbox.nextTurn[0]!.id)
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
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, step: 0, reason: { kind: 'completed' } })
|
||||
expect(agent.inbox.nextTurn).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -747,6 +751,7 @@ describe('turn and step boundary recovery', () => {
|
||||
|
||||
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'])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
})
|
||||
@@ -878,7 +883,7 @@ describe('turn and step boundary recovery', () => {
|
||||
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('contains a pre-step throw after disposal without opening a turn', async () => {
|
||||
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
|
||||
@@ -902,8 +907,10 @@ describe('turn and step boundary recovery', () => {
|
||||
await agent.whenIdle()
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
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)
|
||||
})
|
||||
@@ -1090,7 +1097,7 @@ describe('tool result call identity', () => {
|
||||
})
|
||||
|
||||
describe('disposal and cancellation during pre-step assembly', () => {
|
||||
it('disposal during system-prompt assembly prevents the turn from opening', { 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'])
|
||||
@@ -1134,14 +1141,15 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
unlisten()
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
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 prevents the turn from opening', { 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))
|
||||
@@ -1180,17 +1188,17 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
unlisten()
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
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([])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
|
||||
})
|
||||
|
||||
it('disposal during pre-step prevents the turn from opening', { timeout: 15000 }, async () => {
|
||||
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
|
||||
@@ -1227,16 +1235,16 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await disposalDone
|
||||
await driverDone(agent)
|
||||
|
||||
// The post-listener cancellation check catches disposal before any turn or LLM call.
|
||||
// The post-listener cancellation check catches disposal before any step or LLM call.
|
||||
const e = [...agent.session.events]
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
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([])
|
||||
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'disposed' } }])
|
||||
})
|
||||
|
||||
it('cancel during pre-step prevents the turn from opening', { timeout: 15000 }, async () => {
|
||||
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
|
||||
@@ -1275,11 +1283,11 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await driverDone(agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
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([])
|
||||
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 () => {
|
||||
@@ -1319,8 +1327,10 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await driverDone(agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.some(x => x.type === 'turn/start')).toBe(false)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(false)
|
||||
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)
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('agent/pre-step', () => {
|
||||
expect(events(agent).filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reject 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' })
|
||||
@@ -247,11 +247,11 @@ describe('agent/pre-step', () => {
|
||||
// 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 pre-step for the entered turn', async () => {
|
||||
@@ -274,7 +274,7 @@ describe('agent/pre-step', () => {
|
||||
send(agent, 'entered prompt')
|
||||
await entered.promise
|
||||
expect(agent.status).toBe('running')
|
||||
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' }],
|
||||
@@ -342,7 +342,8 @@ describe('agent/pre-step', () => {
|
||||
{ type: 'text', text: 'staged context' },
|
||||
{ type: 'text', text: 'staged steering' },
|
||||
])
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
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()
|
||||
@@ -398,7 +399,8 @@ describe('agent/pre-step', () => {
|
||||
send(agent, 'later prompt')
|
||||
await idle
|
||||
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
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' },
|
||||
@@ -498,8 +500,9 @@ describe('agent/pre-step', () => {
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'user/message')).toHaveLength(0)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(0)
|
||||
expect(reasons).toEqual([])
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
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' }])
|
||||
|
||||
@@ -537,9 +540,12 @@ describe('agent/pre-step', () => {
|
||||
await idle
|
||||
expect(errors).toEqual([expect.objectContaining({ message: 'prompt hook broke' })])
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(0)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(0)
|
||||
expect(reasons).toEqual([])
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
|
||||
expect(reasons).toEqual([{
|
||||
kind: 'error',
|
||||
error: { message: 'prompt hook broke', code: 'UNKNOWN' },
|
||||
}])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.inbox.nextTurn.map(message => message.content[0]))
|
||||
@@ -775,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)
|
||||
@@ -788,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 () => {
|
||||
|
||||
Reference in New Issue
Block a user