Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/opencode/src/altimate/telemetry/error-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,17 @@ export function describeToolError(raw: string) {
}
}

/**
* True when the tool error is the user declining its permission prompt (with or without
* feedback). That is the user's choice, not a tool failure; rule-based denials and every
* other permission error still count.
*/
export function isUserPermissionRejection(raw: string): boolean {
return /^(?:[\w.]+: )?The user rejected permission to use this specific tool call(?:\.|\s+with the following feedback: )/.test(
raw,
)
}

export function buildErrorFingerprintEvent(
sessionID: string,
err: {
Expand Down
15 changes: 9 additions & 6 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2295,12 +2295,15 @@ export namespace SessionPrompt {
lastToolWasError = true
const errorMsg =
part.state.status === "error" && typeof part.state.error === "string" ? part.state.error : "unknown"
pendingError = {
toolName: part.tool,
toolCategory,
errorClass: Telemetry.classifyError(errorMsg),
...ErrorDetail.describeToolError(errorMsg),
}
// a declined permission prompt is the user's choice, not a tool failure to fingerprint
pendingError = ErrorDetail.isUserPermissionRejection(errorMsg)
? null
: {
toolName: part.tool,
toolCategory,
errorClass: Telemetry.classifyError(errorMsg),
...ErrorDetail.describeToolError(errorMsg),
}
} else {
if (lastToolWasError && pendingError) {
errorRecoveryCount++
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { PermissionNext } from "../../src/permission/next"
import { ErrorDetail } from "../../src/altimate/telemetry/error-detail"

const { isUserPermissionRejection } = ErrorDetail

// The processor stores `error.toString()` on the tool part; prompt.ts fingerprints that string.
describe("user permission rejections are not fingerprinted as tool errors", () => {
test("rejected and corrected prompts are user rejections", () => {
expect(isUserPermissionRejection(new PermissionNext.RejectedError().toString())).toBe(true)
expect(isUserPermissionRejection(new PermissionNext.CorrectedError("use dbt instead").toString())).toBe(true)
expect(isUserPermissionRejection(String(new PermissionV1.RejectedError()))).toBe(true)
expect(isUserPermissionRejection(String(new PermissionV1.CorrectedError({ feedback: "no" })))).toBe(true)
})

test("rule denials and other permission failures are still reported", () => {
expect(isUserPermissionRejection(new PermissionNext.DeniedError([]).toString())).toBe(false)
expect(isUserPermissionRejection(String(new PermissionV1.DeniedError({ ruleset: [] })))).toBe(false)
expect(isUserPermissionRejection("Error: EACCES: permission denied, open '/usr/lib/driver'")).toBe(false)
expect(isUserPermissionRejection("pip failed: The user rejected permission to use this specific tool call.")).toBe(
false,
)
})

test("prompt.ts skips the fingerprint only for user rejections", async () => {
const src = await Bun.file(new URL("../../src/session/prompt.ts", import.meta.url)).text()
expect(src).toContain("pendingError = ErrorDetail.isUserPermissionRejection(errorMsg)")
})
})
Loading