Summary
openab's auto-reply for session/request_permission (in src/acp/connection.rs) sends a JSON-RPC response body with the wrong shape — missing the required outcome wrapper. The response works for Kiro CLI (loose parsing) but is rejected by claude-agent-acp (strict ACP SDK validation), causing every tool call from Claude Code to fail with "User refused permission to run tool", even though openab logs auto-allow permission.
This is also the upstream root cause for the symptom reported in #127 (Cursor's Cannot read properties of undefined (reading 'outcome') error) — the Cursor SDK is trying to read response.outcome.optionId and crashing because response.outcome is undefined.
Current vs spec response shape
src/acp/connection.rs line 99:
let reply = JsonRpcResponse::new(id, json!({"optionId": "allow_always"}));
Per the official ACP TypeScript SDK schema (@agentclientprotocol/sdk types.gen.d.ts):
export type RequestPermissionResponse = {
_meta?: { ... } | null;
/** The user's decision on the permission request. */
outcome: RequestPermissionOutcome;
};
export type RequestPermissionOutcome =
| { outcome: "cancelled"; }
| (SelectedPermissionOutcome & { outcome: "selected"; });
The valid response shape is:
{
"outcome": {
"outcome": "selected",
"optionId": "allow_always"
}
}
openab is sending the inner object's optionId field at the top level, with no outcome wrapper.
Repro
- Run
openab configured with claude-agent-acp backend (any Claude Code agent)
- From Discord, ask the agent to do anything that requires a tool call (e.g.
git pull, read a file, run ls)
openab log shows: INFO openab::acp::connection: auto-allow permission title="..."
claude-agent-acp log shows: tool_call_update status=failed rawOutput="User refused permission to run tool"
- The agent gives up or works around it without ever executing the tool
Trace from a real session (agent-broker:claude running claude-agent-acp@0.25.0 + Claude Code):
18:54:32.071 request_permission for git -C ~/work/trips pull --ff-only
18:54:32.071 auto-allow permission title="git -C ~/work/trips pull --ff-only"
18:54:32.079 tool_call_update status=failed rawOutput="User refused permission to run tool"
8 ms between auto-allow and the failure — claude-agent-acp is parsing openab's reply, finding no outcome field, and treating it as a refusal.
Why Kiro doesn't see this
Kiro CLI's ACP implementation parses the response loosely and accepts {"optionId": "allow_always"} as if it were already inside the outcome wrapper. So this bug is invisible when running the kiro-cli backend, but every Claude Code or Cursor user is hitting it on every tool call.
Suggested fix
Wrap the response correctly:
let reply = JsonRpcResponse::new(id, json!({
"outcome": {
"outcome": "selected",
"optionId": "allow_always"
}
}));
I've patched my local fork with this change and all Claude Code tool calls now succeed end-to-end: Discord mention → claude-agent-acp spawned → request_permission → openab auto-allow → tool executes → result streams back. Verified with a git pull + file edit + git commit -m ... + git push flow.
Also fixes / relates to
Happy to send a PR for the wrap fix alone, or a combined wrap + optionId-selection fix that also closes #111. Just say which scope you prefer.
Summary
openab's auto-reply forsession/request_permission(insrc/acp/connection.rs) sends a JSON-RPC response body with the wrong shape — missing the requiredoutcomewrapper. The response works for Kiro CLI (loose parsing) but is rejected byclaude-agent-acp(strict ACP SDK validation), causing every tool call from Claude Code to fail with "User refused permission to run tool", even thoughopenablogsauto-allow permission.This is also the upstream root cause for the symptom reported in #127 (Cursor's
Cannot read properties of undefined (reading 'outcome')error) — the Cursor SDK is trying to readresponse.outcome.optionIdand crashing becauseresponse.outcomeis undefined.Current vs spec response shape
src/acp/connection.rsline 99:Per the official ACP TypeScript SDK schema (
@agentclientprotocol/sdktypes.gen.d.ts):The valid response shape is:
{ "outcome": { "outcome": "selected", "optionId": "allow_always" } }openabis sending the inner object'soptionIdfield at the top level, with nooutcomewrapper.Repro
openabconfigured withclaude-agent-acpbackend (any Claude Code agent)git pull, read a file, runls)openablog shows:INFO openab::acp::connection: auto-allow permission title="..."claude-agent-acplog shows:tool_call_update status=failed rawOutput="User refused permission to run tool"Trace from a real session (
agent-broker:clauderunningclaude-agent-acp@0.25.0+ Claude Code):8 ms between auto-allow and the failure —
claude-agent-acpis parsingopenab's reply, finding nooutcomefield, and treating it as a refusal.Why Kiro doesn't see this
Kiro CLI's ACP implementation parses the response loosely and accepts
{"optionId": "allow_always"}as if it were already inside theoutcomewrapper. So this bug is invisible when running thekiro-clibackend, but every Claude Code or Cursor user is hitting it on every tool call.Suggested fix
Wrap the response correctly:
I've patched my local fork with this change and all Claude Code tool calls now succeed end-to-end: Discord mention → claude-agent-acp spawned → request_permission → openab auto-allow → tool executes → result streams back. Verified with a
git pull+ file edit +git commit -m ...+git pushflow.Also fixes / relates to
Cannot read properties of undefined (reading 'outcome')— same root cause; the Cursor SDK is readingresponse.outcome.optionId, crashes becauseresponse.outcomeis undefined. The!shshell escape workaround in Docs/FR: Cursor ACP terminal failures (outcome undefined) + macOS launchd notes #127 should no longer be needed after this fix.optionIdselected). Even with fix: ExitPlanMode permission rejected — auto-reply sends invalid optionId #111's proposed fix landed, theoutcomewrapper bug remains. Both fixes need to land. A combined fix would (a) wrap the response correctly, and (b) select the right optionId from theoptionsarray based onkindpriority (allow_always>allow_once> first non-reject_once).Happy to send a PR for the wrap fix alone, or a combined wrap + optionId-selection fix that also closes #111. Just say which scope you prefer.