fix(ui): deny open permission prompts on send (#2445)

Sending a message while a permission prompt is open now denies every
pending permission in the session subtree (optimistically, then formally
via permission.reply reject) and queues the message for next-turn
delivery, mirroring the question-dismiss path from #1740.

- Add dismissOpenPermissionsForSession plus isPermissionRequestNotFoundError
  and removePermissionRequestFromChildStores helpers to session-actions
- Extend dismissPermission with not-found cleanup, parallel to rejectQuestion
- Wire handleSubmit to deny permissions and dismiss questions together,
  queueing once if either prompt type was open
- Add unit tests mirroring the dismissOpenQuestionsForSession suite

Closes #1958
This commit is contained in:
Tom Rochette
2026-07-28 17:36:00 +03:00
committed by GitHub
parent 92abdefea8
commit d50cb5becc
3 changed files with 303 additions and 9 deletions
@@ -9,6 +9,7 @@ const registeredSessionDirectories: Array<{ sessionID: string; directory: string
let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let questionReplyError: unknown | null = null
let questionRejectError: unknown | null = null
let permissionReplyError: unknown | null = null
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
@@ -22,6 +23,10 @@ const mockScopedClient = {
permission: {
reply: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "permission.reply", params })
if (permissionReplyError) {
const status = (permissionReplyError as { status?: number })?.status ?? 404
return Promise.resolve({ error: permissionReplyError, response: { status } })
}
return Promise.resolve({ data: true })
}),
},
@@ -85,6 +90,10 @@ const mockSdk = {
permission: {
reply: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "permission.reply", params })
if (permissionReplyError) {
const status = (permissionReplyError as { status?: number })?.status ?? 404
return Promise.resolve({ error: permissionReplyError, response: { status } })
}
return Promise.resolve({ data: true })
}),
},
@@ -970,6 +979,7 @@ describe("dismissPermission passes directory", () => {
replyCalls.length = 0
scopedClientDirectories.length = 0
questionReplyError = null
permissionReplyError = null
})
test("passes directory and reply=reject", async () => {
@@ -1084,6 +1094,17 @@ function buildQuestion(id: string, sessionId: string): QuestionRequest {
}
}
function buildPermission(id: string, sessionId: string): PermissionRequest {
return {
id,
sessionID: sessionId,
permission: "edit",
patterns: [],
metadata: {},
always: [],
}
}
describe("dismissOpenQuestionsForSession", () => {
beforeEach(() => {
replyCalls.length = 0
@@ -1157,3 +1178,141 @@ describe("dismissOpenQuestionsForSession", () => {
expect(store.getState().question["session-a"]).toBe(undefined)
})
})
describe("dismissPermission not-found handling", () => {
beforeEach(() => {
replyCalls.length = 0
scopedClientDirectories.length = 0
permissionReplyError = null
})
test("clears the stale permission and rethrows on PermissionNotFoundError", async () => {
const permission = buildPermission("perm-stale", "session-a")
const store = createStore({ "session-a": [permission] })
const childStores = createChildStores([["/test/project", store]])
permissionReplyError = Object.assign(new Error("permission.reply failed (404): PermissionNotFoundError"), { status: 404 })
const { setActionRefs, dismissPermission } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
await expect(dismissPermission("session-a", "perm-stale")).rejects.toThrow()
expect(replyCalls.filter((call) => call.method === "permission.reply")).toHaveLength(1)
// The stale entry is cleared from the store even though the server reported not-found.
expect(store.getState().permission["session-a"]).toBe(undefined)
})
test("does not clear the store on a non-not-found failure (rethrow only)", async () => {
const permission = buildPermission("perm-500", "session-a")
const store = createStore({ "session-a": [permission] })
const childStores = createChildStores([["/test/project", store]])
permissionReplyError = Object.assign(new Error("permission.reply failed (500)"), { status: 500 })
const { setActionRefs, dismissPermission } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
await expect(dismissPermission("session-a", "perm-500")).rejects.toThrow()
// A non-not-found failure leaves store reconciliation to the next server event.
expect(store.getState().permission["session-a"]).toHaveLength(1)
})
})
describe("dismissOpenPermissionsForSession", () => {
beforeEach(() => {
replyCalls.length = 0
scopedClientDirectories.length = 0
permissionReplyError = null
})
test("returns false and rejects nothing when no permissions are pending", async () => {
const store = createStore({}, { session: [{ id: "session-a", time: { created: 1 } } as Session] })
const childStores = createChildStores([["/test/project", store]])
const { setActionRefs, dismissOpenPermissionsForSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
const dismissed = await dismissOpenPermissionsForSession("session-a")
expect(dismissed).toBe(false)
expect(replyCalls.filter((call) => call.method === "permission.reply")).toHaveLength(0)
})
test("rejects every pending permission in the session subtree (root + subagent child)", async () => {
const rootPermission = buildPermission("perm-root", "session-a")
const childPermission = buildPermission("perm-child", "session-child")
const store = createStore({
"session-a": [rootPermission],
"session-child": [childPermission],
}, {
session: [
{ id: "session-a", time: { created: 1 } } as Session,
{ id: "session-child", parentID: "session-a", time: { created: 2 } } as Session,
],
})
const childStores = createChildStores([["/test/project", store]])
const { setActionRefs, dismissOpenPermissionsForSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
const dismissed = await dismissOpenPermissionsForSession("session-a")
expect(dismissed).toBe(true)
const replyCallsForPermissions = replyCalls.filter((call) => call.method === "permission.reply")
expect(replyCallsForPermissions).toHaveLength(2)
const rejectedIds = replyCallsForPermissions.map((call) => call.params.requestID).sort()
expect(rejectedIds).toEqual(["perm-child", "perm-root"])
expect(replyCallsForPermissions.every((call) => call.params.reply === "reject")).toBe(true)
// Optimistic clear: the permissions are removed from the local store so the
// prompt disappears instantly, without waiting for the reject round-trip.
expect(store.getState().permission["session-a"]).toBe(undefined)
expect(store.getState().permission["session-child"]).toBe(undefined)
})
test("swallows PermissionNotFoundError so a stranded permission never blocks the send", async () => {
const stalePermission = buildPermission("perm-stale", "session-a")
const store = createStore({ "session-a": [stalePermission] }, {
session: [{ id: "session-a", time: { created: 1 } } as Session],
})
const childStores = createChildStores([["/test/project", store]])
permissionReplyError = Object.assign(new Error("permission.reply failed (404): PermissionNotFoundError"), { status: 404 })
const { setActionRefs, dismissOpenPermissionsForSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
const dismissed = await dismissOpenPermissionsForSession("session-a")
expect(dismissed).toBe(true)
const replyCallsForPermissions = replyCalls.filter((call) => call.method === "permission.reply")
expect(replyCallsForPermissions).toHaveLength(1)
expect(replyCallsForPermissions[0].params.requestID).toBe("perm-stale")
// The stale entry is cleared from the store even though the server reported not-found.
expect(store.getState().permission["session-a"]).toBe(undefined)
})
test("swallows and logs a non-not-found reject failure so the send is never blocked", async () => {
const permission = buildPermission("perm-500", "session-a")
const store = createStore({ "session-a": [permission] }, {
session: [{ id: "session-a", time: { created: 1 } } as Session],
})
const childStores = createChildStores([["/test/project", store]])
permissionReplyError = Object.assign(new Error("permission.reply failed (500)"), { status: 500 })
const { setActionRefs, dismissOpenPermissionsForSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
const errors: unknown[][] = []
const originalError = console.error
console.error = (...args: unknown[]) => { errors.push(args) }
try {
const dismissed = await dismissOpenPermissionsForSession("session-a")
expect(dismissed).toBe(true)
const replyCallsForPermissions = replyCalls.filter((call) => call.method === "permission.reply")
expect(replyCallsForPermissions).toHaveLength(1)
expect(replyCallsForPermissions[0].params.requestID).toBe("perm-500")
expect(errors).toHaveLength(1)
expect(String(errors[0]?.[0])).toContain("[session-actions]")
} finally {
console.error = originalError
}
})
})