fix(agent-loop): open turns before pre-step

This commit is contained in:
_Kerman
2026-08-04 13:49:35 +08:00
parent 0ac95437b4
commit d4fa26023d
154 changed files with 1104 additions and 1099 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 前投影重建被移除的值。

View File

@@ -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) {

View File

@@ -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 () => {

View File

@@ -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()
})
})

View File

@@ -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)

View File

@@ -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 () => {