can1357/oh-my-pi · info · ToolError
Tool call rejected by user (preference)
Error message
Tool call rejected by user (preference)
What it means
During tool permission checks, SessionTools first consults persisted ACP permission decisions keyed by the permission intent's cacheKey. If the stored decision is reject_always, the tool call is immediately rejected with this ToolError — the user previously chose 'always reject' for this tool/argument pattern, and the rejection is replayed without prompting again.
Source
Thrown at packages/coding-agent/src/session/session-tools.ts:786
) => {
const permissionIntent = getPermissionIntent(target.name, args);
if (!permissionIntent) {
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
}
const command =
target.name === "bash" && args && typeof args === "object" && !Array.isArray(args)
? stringProperty(args, "command")
: undefined;
const commandContent = command
? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }]
: undefined;
// Short-circuit on persisted decisions.
const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
if (persisted === "allow_always") {
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
}
if (persisted === "reject_always") {
throw new ToolError(`Tool call rejected by user (preference)`);
}
if (signal?.aborted) {
throw new ToolAbortError("Permission request cancelled");
}
type PermissionRaceResult =
| { kind: "permission"; outcome: ClientBridgePermissionOutcome }
| { kind: "aborted" };
const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<PermissionRaceResult>();
const onAbort = () => resolveAbort({ kind: "aborted" });
signal?.addEventListener("abort", onAbort, { once: true });
let raced: PermissionRaceResult;
try {
const permissionPromise = bridge.requestPermission!(
{
toolCallId,
toolName: target.name,
title: permissionIntent.title,
...(target.name === "bash" ? { kind: "execute" } : {}),View on GitHub (pinned to 9690622007)
Solutions
- Have the user change the permission preference for that tool (re-allow it) via the permission UI/settings, clearing the reject_always decision.
- Modify the tool call so its permission intent differs (different args/path) if a narrower action is genuinely appropriate — it will then prompt normally instead of using the persisted rejection.
- For automated flows, detect this ToolError and skip/ask rather than retrying, since retrying deterministically hits the same persisted decision.
- Treat it as expected control flow: catch ToolError with this message and surface 'denied by user preference' instead of a generic failure.
Example fix
// before
await tool.execute(id, args, signal, onUpdate, ctx); // throws
// after
try {
await tool.execute(id, args, signal, onUpdate, ctx);
} catch (err) {
if (err instanceof ToolError && err.message.includes("rejected by user (preference)")) {
logger.warn("Tool denied by persisted user preference, skipping");
return;
}
throw err;
} Defensive patterns
Strategy: try-catch
Validate before calling
// check the persisted decision before executing, mirroring the library's own short-circuit
const persisted = permissionDecisions.get(permissionIntent.cacheKey);
if (persisted === "reject_always") {
logger.info("Skipping tool call — user preference is reject_always", { tool });
return;
} Type guard
function isUserPreferenceRejection(err: unknown): err is ToolError {
return err instanceof ToolError && err.message === "Tool call rejected by user (preference)";
} Try / catch
try {
await target.execute(toolCallId, args, signal, onUpdate, ctx);
} catch (err) {
if (isUserPreferenceRejection(err)) {
// expected: user permanently denied this tool+args; skip or notify, never auto-retry
return;
}
throw err;
} Prevention
- Never auto-retry calls rejected by preference — the decision is deterministic.
- Surface a UI path for users to revise reject_always decisions.
- Normalize tool args consistently so equivalent calls share one cacheKey (avoids surprising prompt-vs-skip behavior).
- Log the cacheKey with the rejection to make 'why is my tool not running?' debuggable.
When it happens
Trigger: Executing a tool whose permissionIntent.cacheKey matches a previously persisted reject_always decision — i.e. the same tool + normalized args the user permanently denied in an earlier call in this session.
Common situations: A user clicked 'always deny' on a sensitive command earlier and later code (scripts, extensions, retry logic) attempts the same tool call again; automated flows re-running a tool that was blanket-denied.
Related errors
- {}: {error}
- inter-device move failed: {} to {}; unable to remove target:
- Permission denied
- cannot stat {file}: {error}
- failed to read filter definition {}: {e}
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/151d84221d1ea4c0.
Report an issue: GitHub.