{"record":{"id":"151d84221d1ea4c0","repo":"can1357/oh-my-pi","slug":"tool-call-rejected-by-user-preference","errorCode":null,"errorMessage":"Tool call rejected by user (preference)","messagePattern":"Tool call rejected by user \\(preference\\)","errorType":"exception","errorClass":"ToolError","httpStatus":null,"severity":"info","filePath":"packages/coding-agent/src/session/session-tools.ts","lineNumber":786,"sourceCode":"\t\t\t\t) => {\n\t\t\t\t\tconst permissionIntent = getPermissionIntent(target.name, args);\n\t\t\t\t\tif (!permissionIntent) {\n\t\t\t\t\t\treturn await target.execute(toolCallId, args as never, signal, onUpdate, ctx);\n\t\t\t\t\t}\n\t\t\t\t\tconst command =\n\t\t\t\t\t\ttarget.name === \"bash\" && args && typeof args === \"object\" && !Array.isArray(args)\n\t\t\t\t\t\t\t? stringProperty(args, \"command\")\n\t\t\t\t\t\t\t: undefined;\n\t\t\t\t\tconst commandContent = command\n\t\t\t\t\t\t? [{ type: \"content\" as const, content: { type: \"text\" as const, text: `$ ${command}` } }]\n\t\t\t\t\t\t: undefined;\n\t\t\t\t\t// Short-circuit on persisted decisions.\n\t\t\t\t\tconst persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);\n\t\t\t\t\tif (persisted === \"allow_always\") {\n\t\t\t\t\t\treturn await target.execute(toolCallId, args as never, signal, onUpdate, ctx);\n\t\t\t\t\t}\n\t\t\t\t\tif (persisted === \"reject_always\") {\n\t\t\t\t\t\tthrow new ToolError(`Tool call rejected by user (preference)`);\n\t\t\t\t\t}\n\t\t\t\t\tif (signal?.aborted) {\n\t\t\t\t\t\tthrow new ToolAbortError(\"Permission request cancelled\");\n\t\t\t\t\t}\n\t\t\t\t\ttype PermissionRaceResult =\n\t\t\t\t\t\t| { kind: \"permission\"; outcome: ClientBridgePermissionOutcome }\n\t\t\t\t\t\t| { kind: \"aborted\" };\n\t\t\t\t\tconst { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<PermissionRaceResult>();\n\t\t\t\t\tconst onAbort = () => resolveAbort({ kind: \"aborted\" });\n\t\t\t\t\tsignal?.addEventListener(\"abort\", onAbort, { once: true });\n\t\t\t\t\tlet raced: PermissionRaceResult;\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst permissionPromise = bridge.requestPermission!(\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\ttoolCallId,\n\t\t\t\t\t\t\t\ttoolName: target.name,\n\t\t\t\t\t\t\t\ttitle: permissionIntent.title,\n\t\t\t\t\t\t\t\t...(target.name === \"bash\" ? { kind: \"execute\" } : {}),","sourceCodeStart":768,"sourceCodeEnd":804,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/session/session-tools.ts#L768-L804","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nawait tool.execute(id, args, signal, onUpdate, ctx); // throws\n// after\ntry {\n  await tool.execute(id, args, signal, onUpdate, ctx);\n} catch (err) {\n  if (err instanceof ToolError && err.message.includes(\"rejected by user (preference)\")) {\n    logger.warn(\"Tool denied by persisted user preference, skipping\");\n    return;\n  }\n  throw err;\n}","handlingStrategy":"try-catch","validationCode":"// check the persisted decision before executing, mirroring the library's own short-circuit\nconst persisted = permissionDecisions.get(permissionIntent.cacheKey);\nif (persisted === \"reject_always\") {\n  logger.info(\"Skipping tool call — user preference is reject_always\", { tool });\n  return;\n}","typeGuard":"function isUserPreferenceRejection(err: unknown): err is ToolError {\n  return err instanceof ToolError && err.message === \"Tool call rejected by user (preference)\";\n}","tryCatchPattern":"try {\n  await target.execute(toolCallId, args, signal, onUpdate, ctx);\n} catch (err) {\n  if (isUserPreferenceRejection(err)) {\n    // expected: user permanently denied this tool+args; skip or notify, never auto-retry\n    return;\n  }\n  throw err;\n}","preventionTips":["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."],"tags":["permissions","user-preference","tools","acp"],"backgroundTag":"permission-denied-by-preference","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}