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 @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -186,6 +187,8 @@ function altimateTrackError(id: string, args: unknown, ctx: Context, startTime:
input_signature: Telemetry.computeInputSignature(args as Record<string, unknown>),
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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>): 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)
})
})
Loading