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'.
This commit is contained in:
_Kerman
2026-08-03 16:16:37 +08:00
parent 3c160e137d
commit 8b975edf44
2 changed files with 22 additions and 8 deletions

View File

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

View File

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