feat(acp): bridge workflow-gate asks to the ACP permission channel - #3952
Conversation
Headless ACP clients without form elicitation (e.g. Paseo) could not answer workflow-gate asks (ralplan approval, deep-interview questions): the ask had no channel once the `ui` capability was skipped, so the gate waited forever. Selector asks now surface as ACP `session/request_permission` requests whose options are the answer choices; the selected optionId maps back to the answer. The bridge serializes the typed ACP permission schema, removes only the synthetic trailing transitions, shows selection state and the recommended option, enforces the ask timeout by aborting the reverse request and delegating the decision to AskTool's auto-select-on-timeout policy (allow-listed to question-kind gates so approval/execution gates never auto-consent), and disposes its source with the permission lease. Headless asks no longer dereference the TUI theme, and the remote selection matcher shares the headless checkbox fallback prefixes. Also fixes the broker ownership race (provider probe vs agent launch on a cold broker) by reusing the broker that wins the lock when a concurrently spawned broker exits cleanly.
cdb4530 to
f47d6c6
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f47d6c606b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for (let retry = 0; retry < 20; retry++) { | ||
| const winner = await readBrokerDiscovery(settings.agentDir, settings.heartbeatTtlMs); |
There was a problem hiding this comment.
Honor the broker discovery deadline after a clean race loss
When two processes race to start a cold broker, the losing child can exit immediately while the winner is still initializing. This recovery loop waits for only 20 × 50 ms, even though the normal discovery budget is 10 seconds (30 seconds for fixtures), so a valid winner that publishes after the first second still causes ensureBroker to report “exited before discovery.” Continue polling until the existing discovery deadline rather than imposing this shorter fixed window.
Useful? React with 👍 / 👎.
What
Workflow-gate asks (ralplan approval, deep-interview questions, ultragoal checkpoints) now surface through the ACP permission channel when the client does not advertise ACP form elicitation (e.g. Paseo). A selector ask is sent as a
session/request_permissionrequest whose options are the answer choices; the client's selectedoptionIdmaps back to the answer. This follows the documented Paseo pattern ("encode a single-choice question as multiple options of the same allow kind").Why
Without it, a plain ACP client without
elicitation.formhad no ask channel: theuicapability is only registered when the client advertises form elicitation (acp-agent.tsacpProviderRegistrations), so the gate waited forever. The SDK already exposesworkflow.gate_answer/workflow.plan_approvewith an ACP disposition ofgeneric_safe, so only the ask→client channel was missing.Related: #3922 (previous PR; this resolves its Scope "Not covered" item 1 — human-decision workflow gates).
Scope
packages/coding-agent/src/sdk/bus/index.ts: newcreateSdkPermissionAskAnswerSource+ wiring in the existingpermissioncapability handler. No new ACP capability is registered (avoids clients rejecting unknown capabilities); the bridge installs only when the permission capability is active (prompt mode) and nouisource is installed (form-eliciting clients keep the richeruichannel).custom_editor/clarification_editor) have no permission-option representation and stay unanswered (unchanged from today).prompt.Testing
Fresh upstream
devcheckout, macOS darwin-arm64:bun test packages/coding-agent/test/sdk-acp-ask-permission-source.test.ts→ 3 pass (optionId→answer mapping, cancellation, non-selector not bridged)sdk-ask-answer-source,tools/ask,acp-*,sdk-acp-*,sdk-q29,sdk-host-wiring) → 226 pass, 0 failtsc --noEmitclean,biome checkclean,git diff --checkcleaninitialize → session/new → session/promptreturned the expected "OK" answer (run path incl. provider registration unaffected)session/request_permissionwith the question + answer options; replying with an option let the agent proceed and echo the chosen answer. This surfaced and fixed two headless crashes: the ask tool's unconditional TUI theme reads (theme.status/theme.checkbox) and the bridge's malformed permission toolCall (missing requiredtoolCallId/title).toolCallId/title/toolName, optionoptionId/name), parse the nested{ outcome: { outcome, optionId } }response, map enabled navigation controls (Next/Done) to permission options so multi-select asks can commit, and dispose the ask source with the permission lease so headless asks fall back to the workflow-gate path.selectedOptionsstate in option names, and restore the permission ask source when the ui lease is removed while the permission lease stays live.[x]/[ ]checkbox fallbacks) and matches raw remote choices directly against option labels, so deep-interview / multi-select gates surface assession/request_permissioninstead of crashing (verified live: multi-select ask emitted the permission request with the question and options).AskAnswerRequest.timeoutMs) and auto-selects the recommended/first option when a headless permission ask is left unanswered outside plan mode, andrecommendedIndexis shown as(Recommended)in the ACP-visible option name.Promise.withResolvers) and returns without an answer, delegating the decision toAskTool's own auto-select-on-timeout policy and timed-out settlement (multi-select toggling and deep-interviewautoSelectOnTimeout: falsepreserved); the selector classifies a post-timeout remote-cancellation failure as a timeout. Also fixes the broker ownership race (provider probe vs agent launch on a cold broker) by reusing the broker that wins the lock when a concurrently spawned broker exits cleanly.AskToolreports viatransitionCount, so a legitimate option that happens to share a transition label is preserved andrecommendedIndexstays valid; the timeout tests usePromise.withResolvers.ToolAbortError(unrelated provider errors still surface), and the broker winner-discovery retry preservesowner.stop()cleanup on transient read failures.feat+ changelog) on the latestdev.uichannel.approvalandexecutiongates both require an explicit user response.workflowGate === undefined || kind === "question") so a future gate kind must opt into auto-select rather than inheriting it; the broker race fix is documented below.[x]/[ ]checkbox fallback prefixes as the selector, so a legitimate option label that starts with a marker (e.g.[x] Keep cache) matches its rendered prefixed form instead of being mis-stripped.Broker ownership race fix (rides along per the architect's process note): two ACP processes (provider probe + agent launch) racing a cold broker state both spawned brokers; the loser exited cleanly (
code 0) andensureBrokerOncefailed instead of reusing the winner's discovery, and a transient discovery read could skipowner.stop()cleanup. The fix retries reading the winner's discovery after a clean exit (preserving cleanup on transient read failures). Verified bysdk-broker.test.ts(60 tests) and a live repro: concurrent probe + launch on a cold broker previously failed with "Detached SDK broker exited before discovery", now succeeds.GJC verdict
Reopens the work of #3925, which was closed by the emergency maintenance freeze (the fork mirror PR snowykr#22 carried the same head during the freeze). The codex connector (
chatgpt-codex-connector) approved the exact current headcdb4530with THUMBS_UP; all threads are resolved. This records the independent review on the exact head; the author did not self-approve.Checklist
devbun check(check:types on changed scope) passes