Browse Source

fix(acp): settle prompts at whole-agent idle, not the first turn/end

A prompt now resolves only when the agent reaches quiescence: the
correlated turn/end arms an endReason instead of settling immediately,
so steering or injected work that runs further turns before idle no
longer splits one prompt across the automation wire. Token-limit turn
endings settle as end_turn per the README contract (they are not
prompt-level stop reasons); model errors still reject immediately, and
explicit ACP cancellation stays 'cancelled'.
_Kerman 2 months ago
parent
commit
8b975edf44
2 changed files with 22 additions and 8 deletions
  1. 18 6
      packages/acp/acp/src/index.ts
  2. 4 2
      packages/acp/acp/tests/turns.spec.ts

+ 18 - 6
packages/acp/acp/src/index.ts

@@ -92,6 +92,8 @@ interface SessionRecord {
     reject: (error: Error) => void
     messageId: string
     turn: number | undefined
+    /** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */
+    endReason: TurnEndReason | undefined
   } | undefined
 }
 
@@ -171,11 +173,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
       const inflight = record.inflight
       if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
         if (event.data.reason.kind === 'error') {
+          // Model failures surface immediately as prompt errors; ordinary
+          // endings wait for whole-agent idle below.
           record.inflight = undefined
           rejectFromError(inflight, event.data.reason)
         } else {
-          record.inflight = undefined
-          inflight.resolve(turnEndToStopReason(event.data.reason))
+          inflight.endReason = event.data.reason
         }
       }
     }
@@ -282,7 +285,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
           // failure (invalid input) must free the slot again or the session
           // would reject every later prompt as already in flight.
           const inflight: NonNullable<SessionRecord['inflight']> = {
-            resolve, reject, messageId: message.id, turn: undefined,
+            resolve, reject, messageId: message.id, turn: undefined, endReason: undefined,
           }
           record.inflight = inflight
           try {
@@ -297,12 +300,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
             throw internalError(`prompt was not queued: ${detail}`)
           }
           /* v8 ignore stop */
-          // A turnless slot settles only at quiescence: admission discarded
-          // the prompt before it could open a turn.
+          // Settlement waits for whole-agent idle: a correlated turn/end arms
+          // `endReason`, while a turnless slot (admission discarded the
+          // prompt) stays cancelled. Other producers may run further turns
+          // before quiescence; the prompt settles only when the agent stops.
           void record.agent.whenIdle().then(() => {
             if (record.inflight !== inflight) return
             record.inflight = undefined
-            inflight.resolve('cancelled')
+            const end = inflight.endReason
+            if (end === undefined) {
+              inflight.resolve('cancelled')
+            } else {
+              // Token-limit and other non-terminal endings are not prompt-level
+              // stop reasons (see README); only normal quiescence reports end_turn.
+              inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end))
+            }
           })
         })
         return { stopReason }

+ 4 - 2
packages/acp/acp/tests/turns.spec.ts

@@ -31,11 +31,13 @@ describe('ACP prompt lifecycle', () => {
     harness = undefined
   })
 
-  it('maps a max-token turn without losing its committed text', async () => {
+  it('maps a max-token turn to end_turn without losing its committed text', async () => {
     harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] })
     const sessionId = await newSession(harness)
     const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
-    expect(result.stopReason).toBe('max_tokens')
+    // A token-limit turn ending is not a prompt-level stop reason (README):
+    // the prompt settles at whole-agent idle with end_turn.
+    expect(result.stopReason).toBe('end_turn')
     await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
   })