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

  1. Have the user change the permission preference for that tool (re-allow it) via the permission UI/settings, clearing the reject_always decision.
  2. 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.
  3. For automated flows, detect this ToolError and skip/ask rather than retrying, since retrying deterministically hits the same persisted decision.
  4. 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

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


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/151d84221d1ea4c0. Report an issue: GitHub.