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
12 changes: 12 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,18 @@ export function describeToolError(raw: string) {
}
}

// Messages of PermissionNext.RejectedError and CorrectedError, which the processor stores as `error.toString()`.
const USER_REJECTED = "Error: The user rejected permission to use this specific tool call."
const USER_CORRECTED = "Error: The user rejected permission to use this specific tool call with the following feedback: "

/**
* True only when the user answered a permission prompt with "reject": an expected decision, not a tool failure.
* Config-rule denials and every other permission failure still return false and keep being reported.
*/
export function isUserRejection(raw: string): boolean {
return raw === USER_REJECTED || raw.startsWith(USER_CORRECTED)
}

export function buildErrorFingerprintEvent(
sessionID: string,
err: {
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2289,6 +2289,8 @@ export namespace SessionPrompt {
// altimate_change end
if (toolChain.length < 50) toolChain.push(part.tool)
const isError = part.state?.status === "error"
// A user rejecting a permission prompt is their decision, not a tool failure: no fingerprint, no recovery link
if (part.state.status === "error" && typeof part.state.error === "string" && ErrorDetail.isUserRejection(part.state.error)) continue
if (isError) {
toolErrorCount++
// Flush previous unrecovered error before recording new one
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/telemetry/user-rejection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import { ErrorDetail } from "../../src/altimate/telemetry/error-detail"
import { PermissionNext } from "../../src/permission/next"

// The processor stores a failed tool call's error as `error.toString()`; these are the strings prompt.ts sees.
const rejected = new PermissionNext.RejectedError().toString()
const corrected = new PermissionNext.CorrectedError("use grep instead").toString()
const denied = new PermissionNext.DeniedError([{ permission: "glob", pattern: "*", action: "deny" }]).toString()

describe("ErrorDetail.isUserRejection", () => {
test("is the reported glob tool_error fingerprint", () => {
expect(ErrorDetail.templateErrorMessage(rejected)).toBe(
"error the user rejected permission to use this <x> tool call",
)
})

test("matches a user rejecting a permission prompt, with or without feedback", () => {
expect(ErrorDetail.isUserRejection(rejected)).toBe(true)
expect(ErrorDetail.isUserRejection(corrected)).toBe(true)
})

test("keeps reporting config denials and other permission failures", () => {
expect(ErrorDetail.isUserRejection(denied)).toBe(false)
expect(ErrorDetail.isUserRejection("Error: EACCES: permission denied, scandir '/root'")).toBe(false)
expect(ErrorDetail.isUserRejection("Error: unknown")).toBe(false)
})
})
Loading