fix(subagent): preserve continuable behavior after rebase

This commit is contained in:
imccyu
2026-08-01 08:37:21 +08:00
parent 264bc41a13
commit bb6e6d6f3b
22 changed files with 173 additions and 60 deletions

View File

@@ -71,6 +71,7 @@ function createSteeringDelivery(): SteeringDelivery {
return {
receipt: { outcome: promise },
settle(outcome): void {
/* v8 ignore next -- each ownership transfer removes the delivery before another settlement path can reach it. */
if (settled) return
settled = true
resolve(outcome)
@@ -544,6 +545,7 @@ export class ReactLoopAgent implements Agent {
this.drainOutbox(turn)
break steps
}
/* v8 ignore next -- step() folded the same steering predicate into continueTurn immediately before returning. */
if (outcome.continueTurn || this.outbox.some(item => item.steering)) continue
break
case 'request-failed': {

View File

@@ -470,7 +470,10 @@ describe('agent loop', () => {
parameters: {},
async execute() {
// steer while the turn is running (during tool execution)
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }))
agent.send(
createUserMessage({ content: [{ type: 'text', text: 'change of plans' }], source: { kind: 'user' } }),
{ target: 'next-step', wakeup: true },
)
return [{ type: 'text', text: 'tool done' }]
},
}))
@@ -544,6 +547,120 @@ describe('agent loop', () => {
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('pending steering')
})
it('rejects failed steering commits while preserving later context', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering-commit'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.on('agent/step', (subject) => {
if (subject !== agent || receipt !== undefined) return
receipt = subject.steer(createUserMessage({
content: [{ type: 'text', text: 'rejected steering' }],
source: { kind: 'user' },
}))
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'preserved context' }],
source: { kind: 'plugin', plugin: 'loop-test' },
}))
})
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string }
if (event.type === 'steering/message' && !rejected) {
rejected = true
throw new Error('reject steering commit')
}
})
send(agent, 'first prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
if (receipt === undefined) throw new Error('agent/step did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
send(agent, 'recover')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('preserved context')
expect(request).not.toContain('rejected steering')
})
it('rejects committed steering when the step boundary fails', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-step-boundary'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.on('agent/step', (subject) => {
if (subject !== agent || receipt !== undefined) return
receipt = subject.steer(createUserMessage({
content: [{ type: 'text', text: 'committed steering' }],
source: { kind: 'user' },
}))
})
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string }
if (event.type === 'step/start') throw new Error('reject step boundary')
})
send(agent, 'prompt')
await waitForIdle(ctx, agent)
if (receipt === undefined) throw new Error('agent/step did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(true)
expect(agent.session.events.some(event => event.type === 'step/start')).toBe(false)
})
it('retries context and steering after a context commit fails', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-context-commit'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
ctx.on('agent/step', (subject) => {
if (subject !== agent || receipt !== undefined) return
subject.inject(createUserMessage({
content: [{ type: 'text', text: 'preserved context' }],
source: { kind: 'plugin', plugin: 'loop-test' },
}))
receipt = subject.steer(createUserMessage({
content: [{ type: 'text', text: 'preserved steering' }],
source: { kind: 'user' },
}))
})
let rejected = false
ctx.on('internal/dispatch', (_mode, name, args) => {
if (name !== 'session/event') return
const event = args[1] as { type: string; data?: { source?: { kind: string } } }
if (event.type === 'user/message' && event.data?.source?.kind === 'plugin' && !rejected) {
rejected = true
throw new Error('reject context commit')
}
})
send(agent, 'first prompt')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
send(agent, 'recover')
await waitForIdle(ctx, agent)
if (receipt === undefined) throw new Error('agent/step did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 2, step: 1 })
expect(adapter.requests).toHaveLength(1)
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('preserved context')
expect(request).toContain('preserved steering')
})
it('inject() while idle appends context without opening a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -722,12 +839,22 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let receipt: ReturnType<Agent['steer']> | undefined
let contextInjected = false
ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'step/end' || contextInjected) return
contextInjected = true
agent.inject(createUserMessage({
content: [{ type: 'text', text: 'final context' }],
source: { kind: 'plugin', plugin: 'finalize' },
}))
})
ctx.tools.register(defineContentToolFixture({
name: 'finalize',
description: '',
parameters: {},
async execute(_args, exec) {
// Steering lands while the concluding tool is still executing.
// Steering lands while the concluding tool is still executing; the
// step/end listener adds ordinary context after the normal result drain.
receipt = agent.steer(createUserMessage({ content: [{ type: 'text', text: 'late steering' }], source: { kind: 'user' } }))
exec.concludeTurn()
return [{ type: 'text', text: 'final' }]
@@ -744,6 +871,9 @@ describe('agent loop', () => {
if (receipt === undefined) throw new Error('concluding tool did not submit steering')
expect(await receipt.outcome).toEqual({ status: 'rejected' })
expect(events).not.toContain('steering/message')
expect(agent.session.events.some(event => event.type === 'user/message'
&& event.data.source.kind === 'plugin'
&& event.data.content.some(block => block.type === 'text' && block.text === 'final context'))).toBe(true)
send(agent, 'follow up')
await waitForIdle(ctx, agent)
@@ -752,6 +882,7 @@ describe('agent loop', () => {
.flatMap(message => message.content)
.filter(block => block.type === 'text')
.map(block => block.text)
expect(texts).toContain('final context')
expect(texts).not.toContain('late steering')
})

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/README.md
README.md: 8799bc3664b2137b386b752f905e1414fb770cb9
README.zh.md: 851c174ba80bebbab8ee1255cb04be4bcec7eabd
README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734
README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170