fix(vscode): restore permission auto-accept replies

Route live VS Code permission requests directly to directory-scoped permission replies instead of blocking on the refresh-only state preflight.

When auto-accept is enabled after prompts are already visible, reply to the authoritative local requests first, then reconcile and deduplicate the server pending list. Keep state verification for reconnect and refresh flows so stale resolved requests are not resurrected.

Add regression coverage for live events, existing prompts, directory routing, retry behavior, stale reconciliation, and network failures.
This commit is contained in:
Bohdan Triapitsyn
2026-08-01 03:14:59 +03:00
parent 4f63dbf96c
commit f1e8e03c31
7 changed files with 161 additions and 25 deletions
+5 -4
View File
@@ -1183,12 +1183,13 @@ class OpencodeService {
async fetchPermission(
sessionID: string,
requestID: string,
directory?: string,
): Promise<FetchPermissionResult> {
try {
// The V2 path is session-scoped and does not require a `directory`
// parameter. The client-scoped directory (set via setDirectory) is
// honored by the underlying SDK client when the call is routed.
const response = await this.client.v2.session.permission.get({
// The V2 endpoint does not accept a directory parameter. Callers that
// reconcile a known project must therefore select its scoped SDK client.
const client = directory ? this.getScopedSdkClient(directory) : this.client;
const response = await client.v2.session.permission.get({
sessionID,
requestID,
});
+1 -1
View File
@@ -132,7 +132,7 @@ The active-session watchdog in `sync-context.tsx` (per-directory status polls an
Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call.
VS Code does not run the server permission-auto-accept runtime. The extension host persists and broadcasts authoritative policy, while its foreground UI runtime resolves missing child-session lineage through the OpenCode API before deciding whether to suppress and answer a `permission.asked` event. Enabling the policy and reconnect/bootstrap both reconcile pending requests in the session directory, including requests inherited by child sessions. Unknown lineage and exhausted reply retries fail closed and leave the request available for manual action. A later `permission.replied` event invalidates any older deferred ask so the async policy check cannot resurrect a resolved request. With every OpenChamber webview closed or suspended no responder runs; this is an intentional VS Code limitation. Other runtimes remain fully server-owned.
VS Code does not run the server permission-auto-accept runtime. The extension host persists and broadcasts authoritative policy, while its foreground UI runtime resolves missing child-session lineage through the OpenCode API before deciding whether to suppress and answer a `permission.asked` event. Once policy is enabled, a live `permission.asked` event sends the directory-scoped `permission.reply` immediately and does not block on a permission-state preflight request. Enabling the policy treats permission cards already present in the directory store the same way and replies immediately, then reconciles the server's pending list with a state preflight so stale already-resolved requests are not replied to or resurrected. Reconnect/bootstrap also uses the preflight while reconciling pending requests in the session directory, including requests inherited by child sessions. Unknown lineage and exhausted reply retries fail closed and leave the request available for manual action. A later `permission.replied` event invalidates any older deferred ask so the async policy check cannot resurrect a resolved request. With every OpenChamber webview closed or suspended no responder runs; this is an intentional VS Code limitation. Other runtimes remain fully server-owned.
### Mutation responsibility
@@ -926,6 +926,18 @@ describe("respondToPermission passes directory", () => {
expect(replyCalls[0].params.reply).toBe("reject")
expect(replyCalls[0].params.directory).toBe("/fallback/dir")
})
test("uses an explicit event directory before incomplete local routing state", async () => {
const childStores = createChildStores([])
const { setActionRefs, respondToPermission } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/stale/current")
await respondToPermission("unknown-session", "perm-event", "once", "/event/project")
expect(scopedClientDirectories).toContain("/event/project")
expect(replyCalls[0].params.directory).toBe("/event/project")
})
})
describe("revertToMessage passes session directory", () => {
+7 -2
View File
@@ -1137,12 +1137,17 @@ export async function respondToPermission(
sessionId: string,
requestId: string,
response: "once" | "always" | "reject",
directoryOverride?: string,
): Promise<void> {
await waitForConnectionOrThrow()
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
const directory = directoryOverride
|| resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
const client = directoryOverride
? opencodeClient.getScopedSdkClient(directoryOverride)
: getRequestReplyClient("permission", sessionId, requestId)
const result = await client.permission.reply({
requestID: requestId,
reply: response,
...(directory ? { directory } : {}),
+5 -2
View File
@@ -40,7 +40,10 @@ import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import { processVSCodePermissionAutoAccept } from "./vscode-permission-auto-accept"
import {
processVSCodePermissionAutoAccept,
processVSCodeReconciledPermissionAutoAccept,
} from "./vscode-permission-auto-accept"
import { useConfigStore } from "@/stores/useConfigStore"
import { useTodosPersistStore } from "@/stores/useTodosPersistStore"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
@@ -1247,7 +1250,7 @@ export async function resyncBlockingRequestsForDirectory(
const acceptedIdsBySession = new Map<string, Set<string>>()
await Promise.all(Object.entries(grouped).flatMap(([sessionId, permissions]) =>
permissions.map(async (permission) => {
if (!(await processVSCodePermissionAutoAccept(permission, directory))) return
if (!(await processVSCodeReconciledPermissionAutoAccept(permission, directory))) return
const accepted = acceptedIdsBySession.get(sessionId) ?? new Set<string>()
accepted.add(permission.id)
acceptedIdsBySession.set(sessionId, accepted)
@@ -86,6 +86,27 @@ describe("VS Code permission auto-accept runtime", () => {
expect(attempts).toBe(2)
})
test("routes the state check and reply through the permission event directory", async () => {
const stateDirectories: Array<string | undefined> = []
const replyDirectories: Array<string | undefined> = []
const runtime = createVSCodePermissionAutoAcceptRuntime({
getPolicy: () => ({ child: true }),
getSessions: () => new Map(),
getSession: async () => session("child"),
listPendingPermissions: async () => [],
getPermissionState: async (_sessionId, _requestId, directory) => {
stateDirectories.push(directory)
return "ok"
},
reply: async (_sessionId, _requestId, directory) => { replyDirectories.push(directory) },
wait: async () => undefined,
})
expect(await runtime.processPermission(permission, "/permission/project")).toBe(true)
expect(stateDirectories).toEqual(["/permission/project"])
expect(replyDirectories).toEqual(["/permission/project"])
})
test("reconciles existing pending permissions immediately after enablement", async () => {
const replied: string[] = []
const runtime = createVSCodePermissionAutoAcceptRuntime({
@@ -109,19 +130,88 @@ describe("VS Code permission auto-accept runtime", () => {
expect(replied).toEqual(["perm-1"])
})
test("treats an already resolved permission as handled without replying", async () => {
test("accepts visible pending permissions before a network reconciliation failure", async () => {
const replied: string[] = []
let stateChecks = 0
const runtime = createVSCodePermissionAutoAcceptRuntime({
getPolicy: () => ({ child: true }),
getSessions: () => new Map(),
getSession: async () => session("child"),
getKnownPendingPermissions: () => [permission],
listPendingPermissions: async () => { throw new Error("offline") },
getPermissionState: async () => {
stateChecks += 1
return "ok"
},
reply: async (_sessionId, requestId) => { replied.push(requestId) },
wait: async () => undefined,
})
await expect(runtime.reconcilePending("/repo")).rejects.toThrow("offline")
expect(stateChecks).toBe(0)
expect(replied).toEqual(["perm-1"])
})
test("deduplicates visible and network pending permissions", async () => {
let replyCalls = 0
let stateChecks = 0
const runtime = createVSCodePermissionAutoAcceptRuntime({
getPolicy: () => ({ child: true }),
getSessions: () => new Map(),
getSession: async () => session("child"),
getKnownPendingPermissions: () => [permission],
listPendingPermissions: async () => [permission],
getPermissionState: async () => {
stateChecks += 1
return "ok"
},
reply: async () => { replyCalls += 1 },
wait: async () => undefined,
})
await runtime.reconcilePending("/repo")
expect(stateChecks).toBe(0)
expect(replyCalls).toBe(1)
})
test("sends a live-event reply immediately without a permission-state preflight", async () => {
let stateChecks = 0
let replyStarted = false
const runtime = createVSCodePermissionAutoAcceptRuntime({
getPolicy: () => ({ child: true }),
getSessions: () => new Map(),
getSession: async () => session("child"),
listPendingPermissions: async () => [],
getPermissionState: async () => {
stateChecks += 1
return "ok"
},
reply: async () => { replyStarted = true },
wait: async () => undefined,
})
expect(await runtime.processPermission(
{ ...permission, id: "immediate" },
"/repo",
{ verifyPending: false },
)).toBe(true)
expect(stateChecks).toBe(0)
expect(replyStarted).toBe(true)
})
test("keeps the permission-state preflight for refresh reconciliation", async () => {
let replyCalls = 0
const runtime = createVSCodePermissionAutoAcceptRuntime({
getPolicy: () => ({ child: true }),
getSessions: () => new Map(),
getSession: async () => session("child"),
listPendingPermissions: async () => [{ ...permission, id: "resolved" }],
getPermissionState: async () => "resolved",
reply: async () => { replyCalls += 1 },
wait: async () => undefined,
})
expect(await runtime.processPermission({ ...permission, id: "resolved" })).toBe(true)
await runtime.reconcilePending("/repo")
expect(replyCalls).toBe(0)
})
})
@@ -1,7 +1,7 @@
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import { getAllSyncSessionMap } from "./sync-refs"
import { getAllSyncSessionMap, getDirectoryState } from "./sync-refs"
import * as sessionActions from "./session-actions"
const RETRY_DELAYS_MS = [0, 250, 1000]
@@ -10,9 +10,10 @@ type Dependencies = {
getPolicy: () => Record<string, boolean>
getSessions: () => ReadonlyMap<string, Session>
getSession: (sessionId: string, directory?: string) => Promise<Session>
getKnownPendingPermissions?: (directory?: string) => PermissionRequest[]
listPendingPermissions: (directory?: string) => Promise<PermissionRequest[]>
getPermissionState: (sessionId: string, requestId: string) => Promise<"ok" | "resolved" | "unknown">
reply: (sessionId: string, requestId: string) => Promise<void>
getPermissionState: (sessionId: string, requestId: string, directory?: string) => Promise<"ok" | "resolved" | "unknown">
reply: (sessionId: string, requestId: string, directory?: string) => Promise<void>
wait: (delayMs: number) => Promise<void>
}
@@ -48,7 +49,11 @@ export function createVSCodePermissionAutoAcceptRuntime(dependencies: Dependenci
return false
}
const processPermission = (permission: PermissionRequest, directory?: string) => {
const processPermission = (
permission: PermissionRequest,
directory?: string,
options?: { verifyPending?: boolean },
) => {
const recent = recentOutcomes.get(permission.id)
if (recent !== undefined) return Promise.resolve(recent)
const existing = inFlight.get(permission.id)
@@ -57,13 +62,15 @@ export function createVSCodePermissionAutoAcceptRuntime(dependencies: Dependenci
const task = (async () => {
if (!(await isEnabled(permission.sessionID, directory))) return false
const permissionState = await dependencies.getPermissionState(permission.sessionID, permission.id)
if (permissionState === "resolved") return true
if (options?.verifyPending !== false) {
const permissionState = await dependencies.getPermissionState(permission.sessionID, permission.id, directory)
if (permissionState === "resolved") return true
}
for (const delay of RETRY_DELAYS_MS) {
if (delay > 0) await dependencies.wait(delay)
try {
await dependencies.reply(permission.sessionID, permission.id)
await dependencies.reply(permission.sessionID, permission.id, directory)
return true
} catch {
// A failed reply stays visible after the bounded retries.
@@ -87,10 +94,23 @@ export function createVSCodePermissionAutoAcceptRuntime(dependencies: Dependenci
const existing = reconcileInFlight.get(key)
if (existing) return existing
const task = dependencies.listPendingPermissions(directory)
.then(async (permissions) => {
await Promise.all(permissions.map((permission) => processPermission(permission, directory)))
})
const task = (async () => {
const processed = new Set<string>()
const processAll = async (permissions: PermissionRequest[], verifyPending: boolean) => {
const pending = permissions.filter((permission) => {
if (!permission?.id || processed.has(permission.id)) return false
processed.add(permission.id)
return true
})
await Promise.all(pending.map((permission) => processPermission(permission, directory, { verifyPending })))
}
// A permission.asked event is already authoritative local state. Process
// those visible cards before the network reconciliation so enabling the
// toggle works even when permission.list is unavailable or stale.
await processAll(dependencies.getKnownPendingPermissions?.(directory) ?? [], false)
await processAll(await dependencies.listPendingPermissions(directory), true)
})()
.finally(() => reconcileInFlight.delete(key))
reconcileInFlight.set(key, task)
@@ -104,11 +124,16 @@ const runtime = createVSCodePermissionAutoAcceptRuntime({
getPolicy: () => usePermissionStore.getState().autoAccept,
getSessions: getAllSyncSessionMap,
getSession: (sessionId, directory) => opencodeClient.getSession(sessionId, directory),
getKnownPendingPermissions: (directory) => Object.values(getDirectoryState(directory)?.permission ?? {}).flat(),
listPendingPermissions: (directory) => opencodeClient.listPendingPermissions({ directories: [directory] }),
getPermissionState: async (sessionId, requestId) => (await opencodeClient.fetchPermission(sessionId, requestId)).state,
reply: (sessionId, requestId) => sessionActions.respondToPermission(sessionId, requestId, "once"),
getPermissionState: async (sessionId, requestId, directory) => (await opencodeClient.fetchPermission(sessionId, requestId, directory)).state,
reply: (sessionId, requestId, directory) => sessionActions.respondToPermission(sessionId, requestId, "once", directory),
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
})
export const processVSCodePermissionAutoAccept = runtime.processPermission
export const processVSCodePermissionAutoAccept = (
permission: PermissionRequest,
directory?: string,
) => runtime.processPermission(permission, directory, { verifyPending: false })
export const processVSCodeReconciledPermissionAutoAccept = runtime.processPermission
export const reconcileVSCodePendingPermissions = runtime.reconcilePending