Bläddra i källkod

fix(agent-loop): suppress cancel-requested for a no-op keepInbox cancel

cancel() emitted agent/cancel-requested whenever queued or steering work
existed, even under keepInbox with no active turn — a call the contract
documents as a no-op. Consumers could misread that notification as a real
cancellation. Emit only when the call actually aborts the active turn or
discards pending work, matching the "effective call" contract.
_Kerman 2 månader sedan
förälder
incheckning
9676696a31

+ 5 - 1
packages/core/agent-loop/src/agent.ts

@@ -150,7 +150,11 @@ export class ReactLoopAgent implements Agent {
    * all owned by the factory.
    */
   cancel(cause: AgentInterruptReason, options: CancelOptions = {}): void {
-    if (this.abort !== undefined || this.queued.length > 0 || this.outbox.length > 0) {
+    // Effective only when it aborts the active turn or actually discards
+    // pending work: a keepInbox call with no active turn is a documented
+    // no-op, so it must not emit cancel-requested for consumers to misread.
+    const discards = !options.keepInbox && (this.queued.length > 0 || this.outbox.length > 0)
+    if (this.abort !== undefined || discards) {
       // Observe-only: coordination consumers update their state before the
       // inboxes clear; listener failures are contained by the dispatcher.
       if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause)

+ 6 - 1
packages/core/agent-loop/tests/cancel.spec.ts

@@ -104,12 +104,17 @@ describe('Agent.cancel()', () => {
     const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
     const discards: unknown[] = []
     ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
+    const cancelRequests: unknown[] = []
+    ctx.on('agent/cancel-requested', (subject, cause) => { if (subject === agent) cancelRequests.push(cause) })
 
     // Queue a turn WITHOUT waking the driver, so it sits in the inbox.
     agent.send({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
-    // keepInbox cancel: no active turn, work preserved, no discard event.
+    // keepInbox cancel: no active turn, work preserved, no discard event. With
+    // nothing to abort and nothing discarded, the call is a documented no-op,
+    // so it emits no cancel-requested either.
     agent.cancel({ kind: 'user' }, { keepInbox: true })
     expect(discards).toEqual([])
+    expect(cancelRequests).toEqual([])
 
     // The preserved item still runs once the driver is woken by a later send.
     send(agent, 'wake it')