Files
openchamber/packages/ui/src/sync/vscode-permission-auto-accept.ts
T
Bohdan Triapitsyn f1e8e03c31 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.
2026-08-01 03:14:59 +03:00

140 lines
5.8 KiB
TypeScript

import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { opencodeClient } from "@/lib/opencode/client"
import { usePermissionStore } from "@/stores/permissionStore"
import { getAllSyncSessionMap, getDirectoryState } from "./sync-refs"
import * as sessionActions from "./session-actions"
const RETRY_DELAYS_MS = [0, 250, 1000]
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, directory?: string) => Promise<"ok" | "resolved" | "unknown">
reply: (sessionId: string, requestId: string, directory?: string) => Promise<void>
wait: (delayMs: number) => Promise<void>
}
export function createVSCodePermissionAutoAcceptRuntime(dependencies: Dependencies) {
const inFlight = new Map<string, Promise<boolean>>()
const reconcileInFlight = new Map<string, Promise<void>>()
const recentOutcomes = new Map<string, boolean>()
const isEnabled = async (sessionId: string, directory?: string) => {
const policy = dependencies.getPolicy()
const syncedSessions = dependencies.getSessions()
const fetchedSessions = new Map<string, Session>()
const seen = new Set<string>()
let current: string | undefined = sessionId
let currentDirectory = directory
while (current && !seen.has(current)) {
if (Object.prototype.hasOwnProperty.call(policy, current)) return policy[current] === true
seen.add(current)
let session: Session | undefined = syncedSessions.get(current) ?? fetchedSessions.get(current)
if (!session) {
try {
session = await dependencies.getSession(current, currentDirectory)
fetchedSessions.set(session.id, session)
} catch {
return false
}
}
current = session.parentID
currentDirectory = session.directory || currentDirectory
}
return false
}
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)
if (existing) return existing
const task = (async () => {
if (!(await isEnabled(permission.sessionID, directory))) return false
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, directory)
return true
} catch {
// A failed reply stays visible after the bounded retries.
}
}
return false
})().then((accepted) => {
if (accepted) {
recentOutcomes.set(permission.id, true)
setTimeout(() => recentOutcomes.delete(permission.id), 5000)
}
return accepted
}).finally(() => inFlight.delete(permission.id))
inFlight.set(permission.id, task)
return task
}
const reconcilePending = (directory?: string) => {
const key = directory?.trim() || "all"
const existing = reconcileInFlight.get(key)
if (existing) return existing
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)
return task
}
return { processPermission, reconcilePending }
}
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, 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 = (
permission: PermissionRequest,
directory?: string,
) => runtime.processPermission(permission, directory, { verifyPending: false })
export const processVSCodeReconciledPermissionAutoAccept = runtime.processPermission
export const reconcileVSCodePendingPermissions = runtime.reconcilePending