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:
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user