diff --git a/packages/opencode/src/altimate/telemetry/error-detail.ts b/packages/opencode/src/altimate/telemetry/error-detail.ts index a424f6c52a..52e3d5713e 100644 --- a/packages/opencode/src/altimate/telemetry/error-detail.ts +++ b/packages/opencode/src/altimate/telemetry/error-detail.ts @@ -400,6 +400,19 @@ export function describeToolError(raw: string) { } } +/** + * A running tool closed as interrupted after the session's own abort signal fired + * (Stop/Esc, dispose) is a requested cancellation, already reported as the session's + * "aborted" outcome, not a tool failure. Interruptions the session did not request + * (stream error, stall) are still fingerprinted. + */ +export function isRequestedInterruption( + state: { status: string; metadata?: Record } | undefined, + sessionAborted: boolean, +): boolean { + return sessionAborted && state?.status === "error" && state.metadata?.interrupted === true +} + export function buildErrorFingerprintEvent( sessionID: string, err: { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f6e2c0c0f8..2d4b9bd9e1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2194,6 +2194,8 @@ export namespace SessionPrompt { lastToolName = part.tool // altimate_change end if (toolChain.length < 50) toolChain.push(part.tool) + // a tool the user's own abort cut short is not a tool error + if (ErrorDetail.isRequestedInterruption(part.state, abort.aborted)) continue const isError = part.state?.status === "error" if (isError) { toolErrorCount++ diff --git a/packages/opencode/test/telemetry/requested-interruption.test.ts b/packages/opencode/test/telemetry/requested-interruption.test.ts new file mode 100644 index 0000000000..2910aac23d --- /dev/null +++ b/packages/opencode/test/telemetry/requested-interruption.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs" +import path from "path" +import { ErrorDetail } from "../../src/altimate/telemetry/error-detail" + +// The processor closes still-running tools with this exact state when a generation ends. +const interrupted = { status: "error", error: "Tool execution aborted", metadata: { interrupted: true } } + +describe("isRequestedInterruption", () => { + test("user-requested abort of a running tool is not a tool error", () => { + expect(ErrorDetail.isRequestedInterruption(interrupted, true)).toBe(true) + }) + + test("interruption the session did not request is still reported", () => { + expect(ErrorDetail.isRequestedInterruption(interrupted, false)).toBe(false) + }) + + test("ordinary tool errors are still reported after an abort", () => { + expect(ErrorDetail.isRequestedInterruption({ status: "error", metadata: {} }, true)).toBe(false) + expect(ErrorDetail.isRequestedInterruption({ status: "error" }, true)).toBe(false) + expect(ErrorDetail.isRequestedInterruption({ status: "completed", metadata: { interrupted: true } }, true)).toBe( + false, + ) + expect(ErrorDetail.isRequestedInterruption(undefined, true)).toBe(false) + }) + + test("the reported fingerprint is the interrupted-tool error", () => { + expect(ErrorDetail.describeToolError(interrupted.error)).toEqual({ + errorHash: "18cc068fbdbecb19", + errorMessage: "tool aborted", + }) + }) + + test("prompt loop skips requested interruptions before recording error fingerprints", () => { + const src = fs.readFileSync(path.join(__dirname, "../../src/session/prompt.ts"), "utf8") + const guard = src.indexOf("ErrorDetail.isRequestedInterruption(part.state, abort.aborted)) continue") + const record = src.indexOf("...ErrorDetail.describeToolError(errorMsg)") + expect(guard).toBeGreaterThan(-1) + expect(record).toBeGreaterThan(guard) + }) +})