diff --git a/.changeset/dismiss-stale-permissions.md b/.changeset/dismiss-stale-permissions.md new file mode 100644 index 000000000000..0c031b224b22 --- /dev/null +++ b/.changeset/dismiss-stale-permissions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Dismiss stale permission prompts when another view has already answered them. diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts index 3344111a175a..d30e3c75f9ca 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/permission-handler.ts @@ -35,12 +35,16 @@ export function recoverablePermissions(perms: RecoverablePermission[], tracked: } function isNotFoundError(error: unknown): boolean { - if (!error || typeof error !== "object") return false - const obj = error as Record - if (obj.name === "NotFoundError") return true - if (typeof obj.status === "number" && obj.status === 404) return true - const data = obj.data as Record | undefined - return data?.name === "NotFoundError" + const record = (value: unknown) => + value && typeof value === "object" ? (value as Record) : undefined + const obj = record(error) + if (!obj) return false + + const cause = record(obj.cause) + const body = record(cause?.body) + return [obj, record(obj.data), cause, body, record(body?.data)].some( + (value) => value?.name === "NotFoundError" || value?.status === 404, + ) } /** diff --git a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts index 9f288b1d7d81..754143cc2ed8 100644 --- a/packages/kilo-vscode/tests/unit/permission-recovery.test.ts +++ b/packages/kilo-vscode/tests/unit/permission-recovery.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "bun:test" +import { describe, it, expect, spyOn } from "bun:test" import { fetchAndSendPendingPermissions, handlePermissionResponse, @@ -26,6 +26,7 @@ function permissionClient( queries: string[], saves: unknown[] = [], replies: unknown[] = [], + errors?: { save?: unknown; reply?: unknown }, ) { return { permission: { @@ -36,10 +37,12 @@ function permissionClient( }, saveAlwaysRules: async (args: unknown) => { saves.push(args) + if (errors?.save) throw errors.save return { data: true } }, reply: async (args: unknown) => { replies.push(args) + if (errors?.reply) throw errors.reply return { data: true } }, }, @@ -51,8 +54,9 @@ function client( queries: string[], saves: unknown[] = [], replies: unknown[] = [], + errors?: { save?: unknown; reply?: unknown }, ): PermissionContext["client"] { - return permissionClient(permsPerDir, queries, saves, replies) as unknown as PermissionContext["client"] + return permissionClient(permsPerDir, queries, saves, replies, errors) as unknown as PermissionContext["client"] } function ctx(opts: { @@ -60,13 +64,14 @@ function ctx(opts: { dirs?: Map permsPerDir?: Record[]> workspace?: string + errors?: { save?: unknown; reply?: unknown } }) { const messages: unknown[] = [] const queries: string[] = [] const saves: unknown[] = [] const replies: unknown[] = [] const perms = opts.permsPerDir ?? {} - const sdk = client(perms, queries, saves, replies) + const sdk = client(perms, queries, saves, replies, opts.errors) const permDirs = new Map() const fake: PermissionContext = { @@ -136,6 +141,57 @@ describe("handlePermissionResponse", () => { ]) expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }]) }) + + it("treats an SDK-wrapped 404 while saving rules as stale", async () => { + const error = new Error("Permission request not found: p1", { + cause: { status: 404, body: { name: "NotFoundError" } }, + }) + const { fake, messages, saves, replies, permDirs } = ctx({ tracked: ["s1"], errors: { save: error } }) + permDirs.set("p1", "/workspace/.kilo/worktrees/feature") + + await handlePermissionResponse(fake, "p1", "s1", "once", ["bun *"], []) + + expect(saves).toEqual([ + { + requestID: "p1", + directory: "/workspace/.kilo/worktrees/feature", + approvedAlways: ["bun *"], + deniedAlways: [], + }, + ]) + expect(replies).toEqual([]) + expect(permDirs.has("p1")).toBe(false) + expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }]) + }) + + it("treats an SDK-wrapped 404 while replying as stale", async () => { + const error = new Error("Permission request not found: p1", { + cause: { status: 404, body: { name: "NotFoundError" } }, + }) + const { fake, messages, replies, permDirs } = ctx({ tracked: ["s1"], errors: { reply: error } }) + permDirs.set("p1", "/workspace/.kilo/worktrees/feature") + + await handlePermissionResponse(fake, "p1", "s1", "once", [], []) + + expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }]) + expect(permDirs.has("p1")).toBe(false) + expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }]) + }) + + it("does not treat other SDK-wrapped errors as stale", async () => { + const error = new Error("Internal server error", { + cause: { status: 500, body: { name: "InternalServerError" } }, + }) + const { fake, messages, permDirs } = ctx({ tracked: ["s1"], errors: { reply: error } }) + const spy = spyOn(console, "error").mockImplementation(() => {}) + permDirs.set("p1", "/workspace/.kilo/worktrees/feature") + + await handlePermissionResponse(fake, "p1", "s1", "once", [], []) + spy.mockRestore() + + expect(permDirs.has("p1")).toBe(true) + expect(messages).toEqual([{ type: "permissionError", permissionID: "p1" }]) + }) }) describe("recoverablePermissions", () => {