[AI-686] ACP permission & elicitation bridge (daemon half)#286
Conversation
…stration Add the spec-derived ACP session/request_permission and elicitation/create wire DTOs (SessionRequestPermissionParams, PermissionOptionDto, PermissionOutcomeResult, PermissionOutcomeDto, ElicitationCreateParams, ElicitationCreateResult) to AcpMessages.cs, plus the daemon-side mirrors of the server's Task A2 hub-contract types (AcpInteractionRequest, AcpInteractionOption, AcpInteractionDecision, AcpInteractionResolution) to Models.cs. All ten types are registered on CapacitorJsonContext for AOT-safe source-gen serialization (no reflection-based JsonSerializer calls introduced). Wire vocabulary note: PermissionOutcomeDto.Outcome's ACP-JSON spelling is "cancelled" (double-L), distinct from the server's internal InterruptOutcomes.Cancel = "cancel" — the fail-safe cancellation invariant this plan depends on relies on that string never being conflated. Covered by a new AcpInteractionMessagesTests.cs round-trip suite (7 tests) proving: the Acp.* wire DTOs serialize with the explicit camelCase JsonPropertyName vocabulary (sessionId/toolCall/optionId/requestedSchema), optional fields are omitted when null (Options/RequestedSchema on ElicitationCreateParams, OptionId on a cancelled PermissionOutcomeDto), and the server-contract mirror types use the context's default snake_case naming policy end-to-end. Verified: full Capacitor.Cli.Tests.Unit suite (2474/2474) and a clean `dotnet publish -c Release` AOT/trim gate (zero IL2xxx/IL3xxx warnings). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…estAcpInteractionAsync Add PendingAcpInteractionRegistry, a copy-shape of PendingPermissionRegistry parameterized on AcpInteractionDecision instead of PermissionDecision, kept as a separate class (not a generic base) so the two registries can evolve independently. Wire it into ServerConnection: a new _pendingAcpInteractions field, an AcpInteractionResolved push-handler registration alongside the existing PermissionResolved one, and RequestAcpInteractionAsync — which mirrors RequestPermissionAsync's non-blocking-invoke-then-await-push pattern (a short AcpRequestInteraction invocation returns a requestId immediately so it doesn't occupy the connection's single parallel-invocation slot for the whole interaction wait, then awaits the decision via the registry). Covered by PendingAcpInteractionRegistryTests (3 tests): early-arrival buffering, resolve-after-await, and cancellation-token propagation — mirrors PendingPermissionRegistryTests' own coverage of the same race/cancellation shape. Verified: dotnet build src/Capacitor.Cli.Daemon clean; full Capacitor.Cli.Tests.Unit suite 2477/2477 (2474 pre-existing + 3 new); clean dotnet publish -c Release AOT/trim gate (zero IL2xxx/IL3xxx warnings). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fe defaults) Bridges inbound ACP session/request_permission (spec-derived, NOT probe-confirmed) and capability-gated elicitation/create server requests to the server's AcpRequestInteraction hub method via an injected delegate (ServerConnection.RequestAcpInteractionAsync's shape), and maps the returned AcpInteractionDecision back to the ACP JSON-RPC outcome result. MapPermissionDecision is a fail-safe explicit allowlist of affirmative outcomes (allow/allow_once/allow_always/answered) -> selected; every other outcome (deny/cancel/unknown/typo'd), missing/malformed params, a thrown server call, an OperationCanceledException from a torn-down connection, a null SelectedOptionId, or an unresolvable SelectedOptionId all map to cancelled. Resolution matches strictly by OptionId, never by Label, and there is no first-option fallback anywhere in the mapper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…keAcpAgent scripting + end-to-end test Wires AcpInteractionBridge (Task B3) into AcpHostedAgentRuntime.OnServerRequest via a new optional requestInteraction constructor parameter (defaults to null, preserving AI-684's default-decline posture for every existing call site). AcpHostedAgentRuntimeFactory gains a ServerConnection dependency so the real Cursor launch path wires connection.RequestAcpInteractionAsync instead of leaving it null (spec-review Finding 4), plus a connectionSource testability seam (round-4 Finding 3) so AcpHostedAgentRuntimeFactoryTests can drive the REAL factory/StartAsync against an in-memory FakeAcpAgent peer instead of spawning a real cursor-agent process. FakeAcpAgent gains EnqueuePermissionRequestDuringNextPrompt to script an inbound session/request_permission mid-turn, plus SentServerRequests/LastServerRequestResponse/ LastServerRequestError to observe the round trip. Its RunAsync loop now dispatches each line as untracked background work instead of awaiting in-loop — sending a server-request and awaiting its reply from within a single-threaded read loop would otherwise deadlock (the reply can only be read by that same loop). Record() still runs synchronously before any await, so ReceivedCalls ordering for existing strict-order tests is unaffected. Also fixes a latent AcpConnection.HandleServerRequestAsync bug this new path exposed: the final response write used the same (potentially-already-cancelled) token passed to the handler, so a DisposeAsync-triggered cancellation could make the write-gate throw before a byte was written, silently violating the documented "always exactly one response frame" invariant. The response write (and its internal-error fallback) now use CancellationToken.None. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR Summary by QodoAI-686: Bridge ACP permission/elicitation interactions through server (daemon side)
AI Description
Diagram
High-Level Assessment
Files changed (15)
|
Code Review by Qodo
Context used 1.
|
…nId from params, Q3 surface FakeAcpAgent dispatch faults Q1: SessionRequestPermissionParams.Options is non-nullable in C# but System.Text.Json does not enforce that at deserialize time — an omitted or explicit-null `options` field NRE'd inside AcpInteractionBridge's `.Select(...)`, escaping the bridge's own try/catch and surfacing as AcpConnection's generic JSON-RPC "Internal error" (-32603) instead of the bridge's well-formed ACP `cancelled` outcome. Normalize `parsed.Options` once (null array -> empty, null elements filtered) before building both the forwarded AcpInteractionRequest.Options and the MapPermissionDecision call, on both the permission and elicitation paths. Also guard an undefined/default ToolCall JsonElement so it forwards as null rather than a bare JsonElement. Q2: AcpHostedAgentRuntime wired OnServerRequest with `_sessionId ?? ""` — a server->client request arriving before session/new resolves _sessionId (the read loop starts before the handshake completes) forwarded an AcpInteractionRequest with AcpSessionId == "", breaking server-side correlation. AcpInteractionBridge.HandleAsync no longer takes a caller-supplied session id at all; it sources AcpSessionId solely from the request's own params.sessionId, failing safe (cancelled, no server call) when that's missing/empty. Q3: FakeAcpAgent.RunAsync dispatches each inbound line as fire-and-forget background work and previously only Debug.WriteLine'd a fault, so a faulted dispatch manifested to tests as a hang/timeout instead of a clear failure. Capture the first fault via ExceptionDispatchInfo (exposed as DispatchFault) and rethrow it with its original stack trace from DisposeAsync, so a faulted dispatch fails loudly even in tests that don't explicitly check DispatchFault. TDD throughout: new tests written first against the target API, confirmed to fail to compile/pass, then implementation added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
Daemon half of AI-686 — ACP permission & elicitation bridge (child of the AI-682 ACP epic). Wires ACP's blocking human-in-the-loop calls (
session/request_permission, capability-gatedelicitation/create) — which arrive in-process in the daemon ACP runtime — through to the server and back, so a hosted Cursor/ACP agent blocks until a human answers in the Capacitor UI.Server half is a separate PR in kcap-server. This half is developed + tested entirely in kcap-cli; the only cross-repo step is a follow-up
src/clibump in kcap-server after this merges.Changes
AcpInteractionBridge— parses an inbound ACP request, forwards it viaServerConnection.RequestAcpInteractionAsync, awaits the human decision, and maps it back fail-safe:MapPermissionDecisionis an explicit affirmative allowlist (allow/allow_once/allow_always/answered) matched byOptionIdexclusively — any unrecognized/deny/cancel/option-less/null-or-unresolvable-OptionIddecision → the ACPcancelledresponse. Nodefault: selected, no first-option fallback anywhere.PendingAcpInteractionRegistry— id-keyed await/resolve, early-buffer, idempotent, disconnect/cancel → defensivecancelledexactly once (mirrors the provenPendingPermissionRegistry).ServerConnection.RequestAcpInteractionAsync+ theAcpInteractionResolvedpush handler.SessionRequestPermissionParams/PermissionOptionDto/PermissionOutcomeDto/…) inAcpMessages.cs, source-gen registered onCapacitorJsonContext— in lockstep with the server DTOs (ACP-wire outcome spelling"cancelled", distinct from the server-internal"cancel").AcpHostedAgentRuntimeFactorypasses a non-nullrequestInteraction(realServerConnection.RequestAcpInteractionAsync);AcpHostedAgentRuntimewiresAcpConnection.OnServerRequest. Elicitation capability is not advertised (defensive if received). PTY/Claude/Codex runtimes untouched.AcpConnection.HandleServerRequestAsyncused a possibly-cancelled token for its final response write, dropping the disconnect-while-pendingcancelledreply.Testing
Capacitor.Cli.Tests.Unit2498/2498; NativeAOT/trim publish gate clean (no IL2026/IL3050). Covers the full fail-safe mapping matrix, the registry lifecycle,FakeAcpAgent-driven end-to-endsession/request_permission, and the real-factory wiring test (verified to fail ifrequestInteractionregresses to null). Spec-derived wire shapes are marked (the AI-684 probe never reached a permission turn).Review
Built via subagent-driven development: 4 tasks (B1–B4) each TDD'd + per-task-reviewed, then a whole-branch opus review → READY TO MERGE (no Critical/Important; 2 acceptable-follow-up Minors).
🤖 Generated with Claude Code