fix(vscode): resolve permission prompt hangs in VS Code extension

- Handle permission.replied SSE events: map them to permissionResolved
  messages so cascaded and server-resolved permissions clear the webview
  prompt without user action
- Remove optimistic permission removal: replace with a respondingPermissions
  Set that disables buttons while awaiting server confirmation, then the
  SSE-driven permissionResolved clears the prompt
- Notify webview on HTTP failure: handlePermissionResponse now posts
  permissionError to re-enable buttons and show a toast instead of
  silently swallowing the error
- Migrate from deprecated permission.respond to permission.reply endpoint,
  fixing a missing await bug in the legacy server handler
- Recover missed permissions on reconnect and session load: new
  fetchAndSendPendingPermissions() calls GET /permission after SSE
  reconnects and after loadMessages, so events lost during reconnection
  or while the webview was not ready are replayed
This commit is contained in:
Catriel Müller
2026-03-09 12:37:42 -03:00
parent 1aa5e554b3
commit c6950f8365
7 changed files with 211 additions and 63 deletions
+42 -2
View File
@@ -647,6 +647,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
await this.syncWebviewState("sse-connected")
await this.flushPendingSessionRefresh("sse-connected")
await this.fetchAndSendPendingPermissions()
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error)
this.postMessage({
@@ -825,6 +826,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
sessionID,
messages,
})
// Recover any permission.asked events that were missed while the webview
// was loading or during an SSE reconnection (fire-and-forget).
void this.fetchAndSendPendingPermissions()
} catch (error) {
// Silently ignore aborted requests — the user switched to a different session
if (abort.signal.aborted) return
@@ -1515,23 +1520,58 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
response: "once" | "always" | "reject",
): Promise<void> {
if (!this.client) {
this.postMessage({ type: "permissionError", permissionID: permissionId })
return
}
const targetSessionID = sessionID || this.currentSession?.id
if (!targetSessionID) {
console.error("[Kilo New] KiloProvider: No sessionID for permission response")
this.postMessage({ type: "permissionError", permissionID: permissionId })
return
}
try {
const workspaceDir = this.getWorkspaceDirectory(targetSessionID)
await this.client.permission.respond(
{ sessionID: targetSessionID, permissionID: permissionId, response, directory: workspaceDir },
await this.client.permission.reply(
{ requestID: permissionId, reply: response, directory: workspaceDir },
{ throwOnError: true },
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to respond to permission:", error)
this.postMessage({ type: "permissionError", permissionID: permissionId })
}
}
/**
* Fetch all pending permissions from the backend and forward any that belong
* to tracked sessions to the webview. Called after SSE reconnects and after
* loading messages for a session so that missed permission.asked events are
* recovered instead of leaving the server blocked indefinitely.
*/
private async fetchAndSendPendingPermissions(): Promise<void> {
if (!this.client) return
try {
const workspaceDir = this.getWorkspaceDirectory()
const { data } = await this.client.permission.list({ directory: workspaceDir })
if (!data) return
for (const perm of data) {
if (!this.trackedSessionIds.has(perm.sessionID)) continue
this.postMessage({
type: "permissionRequest",
permission: {
id: perm.id,
sessionID: perm.sessionID,
toolName: perm.permission,
patterns: perm.patterns,
args: perm.metadata,
message: `Permission required: ${perm.permission}`,
tool: perm.tool,
},
})
}
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch pending permissions:", error)
}
}
@@ -169,6 +169,8 @@ export type WebviewMessage =
| { type: "todoUpdated"; sessionID: string; items: unknown[] }
| { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } }
| { type: "questionResolved"; requestID: string }
| { type: "permissionResolved"; permissionID: string }
| { type: "permissionError"; permissionID: string }
| { type: "sessionCreated"; session: ReturnType<typeof sessionToWebview> }
| { type: "sessionUpdated"; session: ReturnType<typeof sessionToWebview> }
| null
@@ -228,6 +230,11 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un
tool: event.properties.tool,
},
}
case "permission.replied":
return {
type: "permissionResolved",
permissionID: event.properties.requestID,
}
case "todo.updated":
return {
type: "todoUpdated",
@@ -17,6 +17,7 @@ import type {
EventMessageUpdated,
EventSessionStatus,
EventPermissionAsked,
EventPermissionReplied,
EventTodoUpdated,
EventQuestionAsked,
EventQuestionReplied,
@@ -302,6 +303,42 @@ describe("mapSSEEventToWebviewMessage", () => {
}
})
it("maps permission.replied to permissionResolved", () => {
const event: EventPermissionReplied = {
type: "permission.replied",
properties: { sessionID: "sess-1", requestID: "perm-1", reply: "once" },
}
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
expect(msg?.type).toBe("permissionResolved")
if (msg?.type === "permissionResolved") {
expect(msg.permissionID).toBe("perm-1")
}
})
it("maps permission.replied (always) to permissionResolved", () => {
const event: EventPermissionReplied = {
type: "permission.replied",
properties: { sessionID: "sess-1", requestID: "perm-2", reply: "always" },
}
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
expect(msg?.type).toBe("permissionResolved")
if (msg?.type === "permissionResolved") {
expect(msg.permissionID).toBe("perm-2")
}
})
it("maps permission.replied (reject) to permissionResolved", () => {
const event: EventPermissionReplied = {
type: "permission.replied",
properties: { sessionID: "sess-1", requestID: "perm-3", reply: "reject" },
}
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
expect(msg?.type).toBe("permissionResolved")
if (msg?.type === "permissionResolved") {
expect(msg.permissionID).toBe("perm-3")
}
})
it("maps todo.updated to todoUpdated", () => {
const event: EventTodoUpdated = {
type: "todo.updated",
@@ -8,11 +8,16 @@
* tool call rather than in the bottom dock.
*/
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Component, For, Show, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import { Part, PART_MAPPING, ToolRegistry } from "@kilocode/kilo-ui/message-part"
import { Button } from "@kilocode/kilo-ui/button"
import type { AssistantMessage as SDKAssistantMessage, Part as SDKPart, Message as SDKMessage, ToolPart } from "@kilocode/sdk/v2"
import type {
AssistantMessage as SDKAssistantMessage,
Part as SDKPart,
Message as SDKMessage,
ToolPart,
} from "@kilocode/sdk/v2"
import { useData } from "@kilocode/kilo-ui/context/data"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
@@ -101,13 +106,9 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
// Questions linked to this message (rendered after the last part)
const questionForMessage = () => questions().find((q) => q.tool!.messageID === props.message.id)
const [responding, setResponding] = createSignal(false)
const decide = (permissionId: string, response: "once" | "always" | "reject") => {
if (responding()) return
setResponding(true)
if (session.respondingPermissions().has(permissionId)) return
session.respondToPermission(permissionId, response)
setResponding(false)
}
return (
@@ -117,7 +118,8 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
const perm = () => permissionForPart(part)
// Upstream PART_MAPPING["tool"] returns null for todowrite/todoread,
// so we detect them here and render via ToolRegistry directly.
const isUpstreamSuppressed = part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool)
const isUpstreamSuppressed =
part.type === "tool" && UPSTREAM_SUPPRESSED_TOOLS.has((part as SDKPart & { tool: string }).tool)
return (
<Show when={isUpstreamSuppressed || PART_MAPPING[part.type]}>
<div data-component="tool-part-wrapper" data-permission={!!perm()} data-part-type={part.type}>
@@ -141,49 +143,49 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
// For other tools: show patterns only if they're meaningful (not just '*')
const meaningfulPatterns = p.patterns.filter((pat) => pat !== "*")
return (
<div data-component="permission-prompt" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Show when={!isTodoPerm && meaningfulPatterns.length > 0}>
<div class="permission-dock-patterns">
<For each={meaningfulPatterns}>
{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}
</For>
<div data-component="permission-prompt" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Show when={!isTodoPerm && meaningfulPatterns.length > 0}>
<div class="permission-dock-patterns">
<For each={meaningfulPatterns}>
{(pattern) => <code class="permission-dock-pattern">{pattern}</code>}
</For>
</div>
</Show>
<Show when={isTodoPerm}>
<p data-slot="permission-description">
{p.toolName === "todowrite"
? language.t("settings.permissions.tool.todowrite.description")
: language.t("settings.permissions.tool.todoread.description")}
</p>
</Show>
<div data-slot="permission-actions">
<Button
variant="ghost"
size="small"
onClick={() => decide(p.id, "reject")}
disabled={session.respondingPermissions().has(p.id)}
>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => decide(p.id, "always")}
disabled={session.respondingPermissions().has(p.id)}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => decide(p.id, "once")}
disabled={session.respondingPermissions().has(p.id)}
>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
</Show>
<Show when={isTodoPerm}>
<p data-slot="permission-description">
{p.toolName === "todowrite"
? language.t("settings.permissions.tool.todowrite.description")
: language.t("settings.permissions.tool.todoread.description")}
</p>
</Show>
<div data-slot="permission-actions">
<Button
variant="ghost"
size="small"
onClick={() => decide(p.id, "reject")}
disabled={responding()}
>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => decide(p.id, "always")}
disabled={responding()}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="small"
onClick={() => decide(p.id, "once")}
disabled={responding()}
>
{language.t("ui.permission.allowOnce")}
</Button>
<p data-slot="permission-hint">{language.t("ui.permission.sessionHint")}</p>
</div>
<p data-slot="permission-hint">{language.t("ui.permission.sessionHint")}</p>
</div>
)
}}
</Show>
@@ -3,7 +3,7 @@
* Main chat container that combines all chat components
*/
import { Component, For, Show, createSignal, createEffect, on, onCleanup, onMount } from "solid-js"
import { Component, For, Show, createEffect, on, onCleanup, onMount } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
@@ -54,8 +54,6 @@ export const ChatView: Component<ChatViewProps> = (props) => {
}),
)
const [responding, setResponding] = createSignal(false)
onMount(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && session.status() === "busy") {
@@ -69,10 +67,8 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm || responding()) return
setResponding(true)
if (!perm || session.respondingPermissions().has(perm.id)) return
session.respondToPermission(perm.id, response)
setResponding(false)
}
return (
@@ -111,13 +107,28 @@ export const ChatView: Component<ChatViewProps> = (props) => {
</BasicTool>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={responding()}>
<Button
variant="ghost"
size="small"
onClick={() => decide("reject")}
disabled={session.respondingPermissions().has(perm.id)}
>
{language.t("ui.permission.deny")}
</Button>
<Button variant="secondary" size="small" onClick={() => decide("always")} disabled={responding()}>
<Button
variant="secondary"
size="small"
onClick={() => decide("always")}
disabled={session.respondingPermissions().has(perm.id)}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="small" onClick={() => decide("once")} disabled={responding()}>
<Button
variant="primary"
size="small"
onClick={() => decide("once")}
disabled={session.respondingPermissions().has(perm.id)}
>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
@@ -92,6 +92,7 @@ interface SessionContextValue {
// Pending permission requests
permissions: Accessor<PermissionRequest[]>
respondingPermissions: Accessor<Set<string>>
// Pending question requests
questions: Accessor<QuestionRequest[]>
@@ -175,6 +176,9 @@ export const SessionProvider: ParentComponent = (props) => {
// Pending permissions
const [permissions, setPermissions] = createSignal<PermissionRequest[]>([])
// Permission IDs that have been responded to but not yet confirmed by the server
const [respondingPermissions, setRespondingPermissions] = createSignal<Set<string>>(new Set())
// Pending questions
const [questions, setQuestions] = createSignal<QuestionRequest[]>([])
@@ -395,6 +399,14 @@ export const SessionProvider: ParentComponent = (props) => {
handlePermissionRequest(message.permission)
break
case "permissionResolved":
handlePermissionResolved(message.permissionID)
break
case "permissionError":
handlePermissionError(message.permissionID)
break
case "todoUpdated":
handleTodoUpdated(message.sessionID, message.items)
break
@@ -601,6 +613,30 @@ export const SessionProvider: ParentComponent = (props) => {
setPermissions((prev) => upsertPermission(prev, permission))
}
function handlePermissionResolved(permissionID: string) {
setPermissions((prev) => prev.filter((p) => p.id !== permissionID))
setRespondingPermissions((prev) => {
if (!prev.has(permissionID)) return prev
const next = new Set(prev)
next.delete(permissionID)
return next
})
}
function handlePermissionError(permissionID: string) {
// Remove from responding set so buttons re-enable (permission prompt is still visible)
setRespondingPermissions((prev) => {
if (!prev.has(permissionID)) return prev
const next = new Set(prev)
next.delete(permissionID)
return next
})
showToast({
variant: "error",
title: language.t("settings.permissions.toast.updateFailed.title"),
})
}
function handleQuestionRequest(question: QuestionRequest) {
setQuestions((prev) => {
const idx = prev.findIndex((q) => q.id === question.id)
@@ -904,15 +940,16 @@ export const SessionProvider: ParentComponent = (props) => {
const permission = permissions().find((p) => p.id === permissionId)
const sessionID = permission?.sessionID ?? currentSessionID() ?? ""
// Mark as responding so the UI disables the buttons.
// The permission is removed when the server confirms via permission.replied SSE.
setRespondingPermissions((prev) => new Set(prev).add(permissionId))
vscode.postMessage({
type: "permissionResponse",
permissionId,
sessionID,
response,
})
// Remove from pending permissions
setPermissions((prev) => prev.filter((p) => p.id !== permissionId))
}
function clearQuestionError(requestID: string) {
@@ -957,6 +994,7 @@ export const SessionProvider: ParentComponent = (props) => {
setCloudPreviewId(null)
setLoading(false)
setPermissions([])
setRespondingPermissions(new Set<string>())
setQuestions([])
setQuestionErrors(new Set<string>())
setPendingAgentSelection(defaultAgent())
@@ -1097,6 +1135,7 @@ export const SessionProvider: ParentComponent = (props) => {
getParts,
todos,
permissions,
respondingPermissions,
questions,
questionErrors,
selected,
@@ -386,6 +386,16 @@ export interface PermissionRequestMessage {
permission: PermissionRequest
}
export interface PermissionResolvedMessage {
type: "permissionResolved"
permissionID: string
}
export interface PermissionErrorMessage {
type: "permissionError"
permissionID: string
}
export interface TodoUpdatedMessage {
type: "todoUpdated"
sessionID: string
@@ -955,6 +965,8 @@ export type ExtensionMessage =
| PartUpdatedMessage
| SessionStatusMessage
| PermissionRequestMessage
| PermissionResolvedMessage
| PermissionErrorMessage
| TodoUpdatedMessage
| SessionCreatedMessage
| SessionUpdatedMessage