feat(acp): agent-directed user questions via ACP form elicitation - #14
Conversation
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Runtime E2E test results — ACP user input (
|
Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Oscar Le <oscar.lehuu@gmail.com>
Summary
Agents can now ask the human a question mid-turn and block on the answer. Today nothing of the sort reaches Buzz: we never advertise
clientCapabilities.elicitation.form, soclaude-agent-acpputsAskUserQuestionstraight intodisallowedTools, and if a question did arriveelicitation/createwould fall into the unknown-method arm and get-32601.Permission handling (
session/request_permission) is untouched — it stays auto-approved. This is a separate interaction category, deliberately modelled after ADE'sapprovalvsuserInputsplit.Flow:
Points worth reviewing rather than reading off the diff:
question_0,question_0_custom, Codex's ids) and itsconstwire values are kept in an internalFieldMappingand re-applied on the way back; clients only ever exchangeq0-style ids and display labels. An answer matching a declared option (by value or label) goes to the native field; only an unmatched value goes to the*_customkey. Same rule the permission handler already follows foroptionId.select!, not inline — so cancel, steer and furthersession/updateframes keep being processed, and a pending question can actually be cancelled.cancel_with_cleanupanswers the outstanding JSON-RPC id exactly once, mirroringpending_permission_id.htag is the NIP-29 channel, not the conversation key.batch.channel_idis the sha256-derived conversation/queue id; publishing under it made the relay reject the event asrestricted: not a channel memberin every non-DM channel.#pconstraint — CLI answers carry noptag) and are consumed byQuestionRuntimeonly, so an answer never starts a new prompt turn.is_owner_or_sibling; first valid answer wins, later and non-owner answers are dropped.is_workflow_execution_kind's46001..=46012range, which is a range check that future workflow kinds would silently extend over ours.buzz user-input list|answerexists because it is the only way to exercise the flow until the desktop card lands; pending state is derived client-side from request/answer events (no durable pending projection yet), counting only answers the caller authored.Not in this PR: desktop/mobile question cards, chat-reply fallback, durable pending-interaction state with restart reconciliation, and Goose's
_goose/unstable/elicitation/*variant.Related issue
None found.
Testing
Unit: schema normalization (single/multi-select, freeform, unsupported → cancel), option-vs-custom answer routing, natural question ordering with ≥11 fields, optional vs required fields, engine-key reconstruction, owner-only + first-answer-wins routing, exactly-one cancel response when a turn is cancelled with a question pending, and — with paused Tokio time — that neither the idle nor the hard deadline fires during a long pending window. Plus subscription-shape tests for the default Mentions filter and the answer REQ.
Real-process E2E against a local relay (Postgres+Redis) with a real
buzz-acpharness driving an ACP-speaking fake agent, in a normal stream channel: question published and listed as pending,buzz user-input answer '{"q0":"production","q1":["lint","tests"]}'→ the agent receives exactly one{"action":"accept","content":{"question_0":"production","question_1":["lint","tests"]}}, and no second turn is triggered. Also verified live: a 150 s pending question survives--idle-timeout 20 --max-turn-duration 40(control run with a silent agent times out at 20 s, proving the timers were armed), cancel-while-pending emits exactly onecancel, non-owner and late answers are ignored, andBUZZ_ACP_NO_USER_INPUT=trueadvertises"elicitation":{"form":false}and cancels any question. Full evidence in the test-results comment.The mobile Flutter CI stage was not run locally (hangs in this environment); no Dart files change here. No UI change.
Link to Devin session: https://app.devin.ai/sessions/c69bb63719224c65964381e6b09210dc
Requested by: @oscarlehuu