diff --git a/packages/opencode/src/altimate/telemetry/error-detail.ts b/packages/opencode/src/altimate/telemetry/error-detail.ts index 3d2ef5e78d..781c2855aa 100644 --- a/packages/opencode/src/altimate/telemetry/error-detail.ts +++ b/packages/opencode/src/altimate/telemetry/error-detail.ts @@ -392,6 +392,18 @@ export function templateErrorMessage(raw: string): string { return out.join(" ").slice(0, MAX_MESSAGE) } +/** + * True only for the exact messages of PermissionV1.RejectedError / CorrectedError (the user + * declining a permission prompt, with or without feedback), optionally prefixed by the + * error name from toString(). That is a user decision, not a tool fault; config-rule + * denials and real "permission denied" failures do not match and are still reported. + */ +export function isUserPermissionRejection(raw: string): boolean { + return /^(?:[A-Za-z.]*Error: )?The user rejected permission to use this specific tool call(?:\.$| with the following feedback: )/.test( + raw, + ) +} + /** Hash input is unchanged from before the detail fields existed, so fingerprints stay stable. */ export function describeToolError(raw: string) { return { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 11a455551d..9fb67537d5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2289,6 +2289,17 @@ export namespace SessionPrompt { // altimate_change end if (toolChain.length < 50) toolChain.push(part.tool) const isError = part.state?.status === "error" + // altimate_change start — a user declining a permission prompt is a decision, not a tool fault: no error fingerprint + if ( + isError && + part.state.status === "error" && + typeof part.state.error === "string" && + ErrorDetail.isUserPermissionRejection(part.state.error) + ) { + toolErrorCount++ + continue + } + // altimate_change end if (isError) { toolErrorCount++ // Flush previous unrecovered error before recording new one diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 4558050b1d..0c9cd5d982 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -8,6 +8,7 @@ import { Agent } from "@/agent/agent" // altimate_change start — telemetry instrumentation + legacy zod-tool adapter import z from "zod" import { Telemetry } from "../altimate/telemetry" +import { ErrorDetail } from "../altimate/telemetry/error-detail" // altimate_change start — debug mode tool tracing import { traceToolCall } from "@/altimate/debug/mode" // altimate_change end @@ -186,6 +187,8 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime: input_signature: Telemetry.computeInputSignature(args as Record), error: maskedErrorMsg, }) + // a user declining the permission prompt is a decision, not a core failure + if (ErrorDetail.isUserPermissionRejection(errorMsg)) return Telemetry.track({ type: "core_failure", timestamp: Date.now(), diff --git a/packages/opencode/test/altimate/permission-rejection-telemetry.test.ts b/packages/opencode/test/altimate/permission-rejection-telemetry.test.ts new file mode 100644 index 0000000000..6c9c81b5c7 --- /dev/null +++ b/packages/opencode/test/altimate/permission-rejection-telemetry.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import z from "zod" +import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Tool } from "../../src/tool/tool" +import { Telemetry } from "../../src/altimate/telemetry" +import { ErrorDetail } from "../../src/altimate/telemetry/error-detail" +import { PermissionNext } from "../../src/permission/next" +import { initTool } from "./tool-fixture" + +const spies: Array<{ mockRestore(): void }> = [] +afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore() +}) + +function ctx(ask: () => Promise): any { + return { + sessionID: "ses_perm_reject", + messageID: "msg_perm_reject", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata() {}, + ask, + } +} + +async function run(error: Error) { + const events: Telemetry.Event[] = [] + spies.push(spyOn(Telemetry, "track").mockImplementation((event) => void events.push(event))) + const tool = await initTool( + Tool.define("perm_fixture", { + description: "Permission rejection fixture", + parameters: z.object({}), + async execute(_args, c) { + await c.ask({ permission: "perm_fixture", patterns: ["*"], always: ["*"], metadata: {} }) + return { title: "", output: "ok", metadata: {} } + }, + }), + ) + await expect( + tool.execute( + {}, + ctx(async () => { + throw error + }), + ), + ).rejects.toBeDefined() + return events +} + +describe("user permission rejection telemetry", () => { + test.each([ + ["PermissionNext.RejectedError toString", new PermissionNext.RejectedError().toString()], + ["PermissionV1.RejectedError message", new PermissionV1.RejectedError().message], + ["PermissionV1.CorrectedError message", new PermissionV1.CorrectedError({ feedback: "use glob" }).message], + ["PermissionNext.CorrectedError toString", new PermissionNext.CorrectedError("no").toString()], + ])("%s is recognised as a user decision", (_label, raw) => { + expect(ErrorDetail.isUserPermissionRejection(raw)).toBe(true) + }) + + test.each([ + new PermissionV1.DeniedError({ ruleset: [] }).message, + "EACCES: permission denied, open '/etc/shadow'", + "Access denied for user", + "tool failed: The user rejected permission to use this specific tool call.", + ])("other permission failures are still reported: %s", (raw) => { + expect(ErrorDetail.isUserPermissionRejection(raw)).toBe(false) + }) + + test("a rejected prompt emits no core_failure", async () => { + const events = await run(new PermissionNext.RejectedError()) + expect(events.find((e) => e.type === "tool_call")).toMatchObject({ status: "error" }) + expect(events.some((e) => e.type === "core_failure")).toBe(false) + }) + + test("a config-rule denial still emits core_failure", async () => { + const events = await run(new PermissionNext.DeniedError([])) + expect(events.some((e) => e.type === "core_failure")).toBe(true) + }) +})