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 reject: (error: Error) => void
messageId: string messageId: string
turn: number | undefined turn: number | undefined
/** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */
endReason: TurnEndReason | undefined
} | undefined } | undefined
} }
@@ -171,11 +173,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
const inflight = record.inflight const inflight = record.inflight
if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
if (event.data.reason.kind === 'error') { if (event.data.reason.kind === 'error') {
// Model failures surface immediately as prompt errors; ordinary
// endings wait for whole-agent idle below.
record.inflight = undefined record.inflight = undefined
rejectFromError(inflight, event.data.reason) rejectFromError(inflight, event.data.reason)
} else { } else {
record.inflight = undefined inflight.endReason = event.data.reason
inflight.resolve(turnEndToStopReason(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 // failure (invalid input) must free the slot again or the session
// would reject every later prompt as already in flight. // would reject every later prompt as already in flight.
const inflight: NonNullable<SessionRecord['inflight']> = { const inflight: NonNullable<SessionRecord['inflight']> = {
resolve, reject, messageId: message.id, turn: undefined, resolve, reject, messageId: message.id, turn: undefined, endReason: undefined,
} }
record.inflight = inflight record.inflight = inflight
try { try {
@@ -297,12 +300,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
throw internalError(`prompt was not queued: ${detail}`) throw internalError(`prompt was not queued: ${detail}`)
} }
/* v8 ignore stop */ /* v8 ignore stop */
// A turnless slot settles only at quiescence: admission discarded // Settlement waits for whole-agent idle: a correlated turn/end arms
// the prompt before it could open a turn. // `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(() => { void record.agent.whenIdle().then(() => {
if (record.inflight !== inflight) return if (record.inflight !== inflight) return
record.inflight = undefined 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 } return { stopReason }

View File

@@ -31,11 +31,13 @@ describe('ACP prompt lifecycle', () => {
harness = undefined 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')] }) harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] })
const sessionId = await newSession(harness) const sessionId = await newSession(harness)
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) 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') }) await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
}) })