From 540405bf338f116ec261891ef3820982bb0c64fe Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 18:25:30 -0400 Subject: [PATCH] fix(vscode): apply /yolo and /auto while a turn is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing /yolo mid-turn did nothing: the webview queues every input while streaming, and a queued command cannot run because the turn is parked on the approval prompt the user is trying to switch off. Even unqueued, the host framed the command as a turn and beginHostAction rejects it as busy. Permission commands now take a control path — a dedicated bridge method that changes the mode without turn framing, mirroring the CLI, where both commands are available while streaming. Flipping to yolo or auto also answers the approvals already on screen, since the engine asked for them before the mode changed. setPermissionMode no longer short-circuits on the cached mode; it always reconciles with the engine, so a drifted cache cannot silently report the mode as already set. --- .../vscode-permission-command-mid-turn.md | 5 ++ apps/vscode/shared/bridge.ts | 7 ++ apps/vscode/src/handlers/chat.handler.ts | 25 ++++++- apps/vscode/src/handlers/slash-command.ts | 59 ++++++++++++---- apps/vscode/src/runtime/session-runtime.ts | 12 +++- apps/vscode/test/bridge-handler.test.ts | 31 ++++++++ apps/vscode/test/event-handlers.test.ts | 70 ++++++++++++++++++- apps/vscode/test/session-runtime.test.ts | 23 ++++++ apps/vscode/webview-ui/src/services/bridge.ts | 7 ++ .../webview-ui/src/stores/chat.store.ts | 51 ++++++++++++++ 10 files changed, 270 insertions(+), 20 deletions(-) create mode 100644 .changeset/vscode-permission-command-mid-turn.md diff --git a/.changeset/vscode-permission-command-mid-turn.md b/.changeset/vscode-permission-command-mid-turn.md new file mode 100644 index 00000000..6e0e7b66 --- /dev/null +++ b/.changeset/vscode-permission-command-mid-turn.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Let `/yolo` and `/auto` take effect in the VS Code extension while the agent is running, and auto-approve the requests already waiting on screen. diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts index a8116ae7..896f650d 100644 --- a/apps/vscode/shared/bridge.ts +++ b/apps/vscode/shared/bridge.ts @@ -42,6 +42,7 @@ export const Methods = { AbortChat: "abortChat", ResetSession: "resetSession", SetPlanMode: "setPlanMode", + SetPermissionMode: "setPermissionMode", SteerChat: "steerChat", RespondApproval: "respondApproval", @@ -208,6 +209,12 @@ function validateParams(method: RpcMethod, params: unknown): boolean { && isStringRecord(params["answers"]); case Methods.SetPlanMode: return hasBoolean(params, "enabled"); + case Methods.SetPermissionMode: + return isPlainObject(params) + && (params["mode"] === "yolo" || params["mode"] === "auto") + && (params["request"] === "on" + || params["request"] === "off" + || params["request"] === "toggle"); case Methods.SteerChat: return isPlainObject(params) && isContent(params["content"]); case Methods.GetProjectFiles: diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts index ba46b4e0..ba7bffa2 100644 --- a/apps/vscode/src/handlers/chat.handler.ts +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { isPythinkerError } from "@pythoughts/pythinker-code-sdk"; +import { isPythinkerError, type PermissionMode } from "@pythoughts/pythinker-code-sdk"; import { Events, Methods } from "../../shared/bridge"; import type { ApprovalResponse, ContentPart } from "../../shared/legacy-sdk"; @@ -9,7 +9,12 @@ import { VSCodeSettings } from "../config/vscode-settings"; import { normalizeEffort } from "../runtime/pythinker-runtime"; import type { SessionRuntime } from "../runtime/session-runtime"; import { isWorkspacePathContained, relativeWorkspacePath } from "../utils/workspace-path"; -import { parseHostSlashCommand, runHostSlashCommand } from "./slash-command"; +import { + applyPermissionCommand, + parseHostSlashCommand, + runHostSlashCommand, + type PermissionCommandRequest, +} from "./slash-command"; import type { Handler } from "./types"; interface StreamChatParams { @@ -169,6 +174,21 @@ const setPlanMode: Handler<{ enabled: boolean }, { ok: boolean; planMode: boolea return { ok: true, planMode: params.enabled }; }; +/** + * `/yolo` and `/auto` are control commands, not turns: the webview sends them + * here instead of through the chat queue so they still take effect while the + * agent is running — which is exactly when a pending approval blocks it. + */ +const setPermissionMode: Handler< + { mode: "yolo" | "auto"; request: PermissionCommandRequest }, + { ok: boolean; mode?: PermissionMode; message?: string } +> = async (params, ctx) => { + const runtime = ctx.getSession(); + if (runtime === undefined) return { ok: false }; + const result = await applyPermissionCommand(runtime, params.mode, params.request); + return { ok: true, mode: result.mode, message: result.message }; +}; + const steerChat: Handler<{ content: string | ContentPart[] }, { ok: boolean }> = async (params, ctx) => { const runtime = ctx.getSession(); if (runtime === undefined || !runtime.isBusy) return { ok: false }; @@ -190,6 +210,7 @@ export const chatHandlers: Record> = { [Methods.RespondApproval]: respondApproval, [Methods.RespondQuestion]: respondQuestion, [Methods.SetPlanMode]: setPlanMode, + [Methods.SetPermissionMode]: setPermissionMode, [Methods.SteerChat]: steerChat, [Methods.ResetSession]: resetSession, }; diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts index f9ccafe3..aad6a89f 100644 --- a/apps/vscode/src/handlers/slash-command.ts +++ b/apps/vscode/src/handlers/slash-command.ts @@ -154,20 +154,34 @@ const PERMISSION_MODE_DISABLED_MESSAGE = { auto: "Auto mode disabled. You are back at the keyboard.", } as const; -/** `/yolo` and `/auto` accept `on` and `off`, and toggle without an argument — as the CLI does. */ -async function runPermissionCommand( +/** `on`, `off`, or a bare toggle — the argument forms `/yolo` and `/auto` accept. */ +export type PermissionCommandRequest = "on" | "off" | "toggle"; + +export function parsePermissionCommandRequest(args: string): PermissionCommandRequest { + const subcommand = args.trim().toLowerCase(); + if (subcommand === "on") return "on"; + if (subcommand === "off") return "off"; + return "toggle"; +} + +/** + * Applies a `/yolo` or `/auto` request and reports the resulting mode. Callers + * own how the message is surfaced, so this runs identically whether the command + * came in between turns or mid-turn over the bridge. + */ +export async function applyPermissionCommand( runtime: SessionRuntime, mode: "yolo" | "auto", - args: string, - emit: (text: string) => void, -): Promise { - const subcommand = args.trim().toLowerCase(); - const requested = - subcommand === "on" ? mode : subcommand === "off" ? "manual" : undefined; + request: PermissionCommandRequest, +): Promise<{ mode: PermissionMode; message: string }> { + const requested = request === "on" ? mode : request === "off" ? "manual" : undefined; if (requested !== undefined && runtime.permissionMode === requested) { - emit(requested === mode ? `${label(mode)} is already on.` : `${label(mode)} is already off.`); - return; + return { + mode: requested, + message: + requested === mode ? `${label(mode)} is already on.` : `${label(mode)} is already off.`, + }; } let current: PermissionMode; @@ -178,11 +192,28 @@ async function runPermissionCommand( current = requested; } - emit( - current === mode - ? PERMISSION_MODE_ENABLED_MESSAGE[mode] - : PERMISSION_MODE_DISABLED_MESSAGE[mode], + return { + mode: current, + message: + current === mode + ? PERMISSION_MODE_ENABLED_MESSAGE[mode] + : PERMISSION_MODE_DISABLED_MESSAGE[mode], + }; +} + +/** `/yolo` and `/auto` accept `on` and `off`, and toggle without an argument — as the CLI does. */ +async function runPermissionCommand( + runtime: SessionRuntime, + mode: "yolo" | "auto", + args: string, + emit: (text: string) => void, +): Promise { + const result = await applyPermissionCommand( + runtime, + mode, + parsePermissionCommandRequest(args), ); + emit(result.message); } function label(mode: "yolo" | "auto"): string { diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts index cf16e44c..410b03aa 100644 --- a/apps/vscode/src/runtime/session-runtime.ts +++ b/apps/vscode/src/runtime/session-runtime.ts @@ -131,13 +131,19 @@ export class SessionRuntime { return next; } + /** + * Always reconciles against the engine rather than trusting the cached mode: + * a cache that drifted would otherwise report the mode as already set and + * never call through. + */ async setPermissionMode(mode: PermissionMode): Promise { - if (this.currentPermissionMode === mode) return; this.ensureOpen(); const status = await this.session.getStatus(); if (status.permission !== mode) await this.session.setPermission(mode); - await persistPermissionMode(this.session, mode); - this.currentPermissionMode = mode; + if (this.currentPermissionMode !== mode) { + await persistPermissionMode(this.session, mode); + this.currentPermissionMode = mode; + } } subscribe(webviewId: string): void { diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts index 40330c30..417c0cd9 100644 --- a/apps/vscode/test/bridge-handler.test.ts +++ b/apps/vscode/test/bridge-handler.test.ts @@ -149,6 +149,37 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () => expect(cancel).toHaveBeenCalledOnce(); }); + it("changes the permission mode of a running session without starting a turn", async () => { + const setPermissionMode = vi.fn(async () => undefined); + vi.spyOn(bridge.runtime, "getSessionForView").mockReturnValue({ + permissionMode: "manual", + setPermissionMode, + beginHostAction: () => { + throw new Error("A host action must not frame a permission change"); + }, + } as never); + + const result = await bridge.handle( + { id: "rpc-1", method: Methods.SetPermissionMode, params: { mode: "yolo", request: "on" } }, + "view-1", + ); + + expect(setPermissionMode).toHaveBeenCalledWith("yolo"); + expect(result).toMatchObject({ id: "rpc-1", result: { ok: true, mode: "yolo" } }); + }); + + it("rejects a permission change for a mode it does not control", async () => { + const result = await bridge.handle( + { id: "rpc-1", method: Methods.SetPermissionMode, params: { mode: "plan", request: "on" } }, + "view-1", + ); + + expect(result).toEqual({ + id: "rpc-1", + error: "Invalid bridge params for method: setPermissionMode", + }); + }); + it.each(["missingMethod", "toString", "constructor", "__proto__"])( "does not dispatch the unknown or prototype method %s", async (method) => { diff --git a/apps/vscode/test/event-handlers.test.ts b/apps/vscode/test/event-handlers.test.ts index a82ac0ff..82b6b7cb 100644 --- a/apps/vscode/test/event-handlers.test.ts +++ b/apps/vscode/test/event-handlers.test.ts @@ -7,6 +7,7 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useChatStore } from "../webview-ui/src/stores/chat.store"; +import { useApprovalStore } from "../webview-ui/src/stores/approval.store"; import { deriveWorkflowLanes, maxLaneStepCount } from "../webview-ui/src/lib/workflow-lanes"; import type { UIStepItem } from "../webview-ui/src/stores/chat.store"; @@ -17,6 +18,9 @@ const boundary = vi.hoisted(() => ({ trackFiles: vi.fn(), toastError: vi.fn(), toastWarning: vi.fn(), + toastSuccess: vi.fn(), + setPermissionMode: vi.fn(), + respondApproval: vi.fn(), })); vi.mock("@/services", () => ({ @@ -25,10 +29,16 @@ vi.mock("@/services", () => ({ streamChat: boundary.streamChat, abortChat: boundary.abortChat, trackFiles: boundary.trackFiles, + setPermissionMode: boundary.setPermissionMode, + respondApproval: boundary.respondApproval, }, })); vi.mock("@/components/ui/sonner", () => ({ - toast: { error: boundary.toastError, warning: boundary.toastWarning }, + toast: { + error: boundary.toastError, + warning: boundary.toastWarning, + success: boundary.toastSuccess, + }, })); beforeEach(() => { @@ -270,3 +280,61 @@ describe("workflow lane derivation", () => { expect(maxLaneStepCount(lanes)).toBe(2); }); }); + +describe("permission slash commands (control path, not a queued turn)", () => { + beforeEach(() => { + boundary.setPermissionMode.mockReset(); + boundary.respondApproval.mockReset(); + boundary.toastSuccess.mockReset(); + boundary.respondApproval.mockResolvedValue({ ok: true }); + useApprovalStore.setState({ pending: [] }); + }); + + it("sends /yolo straight through while a turn is streaming instead of queueing it", async () => { + boundary.setPermissionMode.mockResolvedValue({ + ok: true, + mode: "yolo", + message: "You only live once!", + }); + useChatStore.setState({ isStreaming: true, queue: [] }); + + useChatStore.getState().sendMessage("/yolo"); + await vi.waitFor(() => expect(boundary.setPermissionMode).toHaveBeenCalled()); + + expect(boundary.setPermissionMode).toHaveBeenCalledWith("yolo", "toggle"); + expect(useChatStore.getState().queue).toHaveLength(0); + expect(boundary.streamChat).not.toHaveBeenCalled(); + }); + + it("answers the approvals already on screen when a turn is unblocked by /yolo", async () => { + boundary.setPermissionMode.mockResolvedValue({ ok: true, mode: "yolo", message: "on" }); + useApprovalStore.setState({ + pending: [ + { + id: "approval-1", + tool_call_id: "call-1", + sender: "Bash", + action: "run", + description: "grep", + display: [], + }, + ], + }); + useChatStore.setState({ isStreaming: true, queue: [] }); + + useChatStore.getState().sendMessage("/yolo on"); + await vi.waitFor(() => expect(boundary.respondApproval).toHaveBeenCalled()); + + expect(boundary.respondApproval).toHaveBeenCalledWith("approval-1", "approve_for_session"); + expect(useApprovalStore.getState().pending).toHaveLength(0); + }); + + it("still queues an ordinary message while streaming", () => { + useChatStore.setState({ isStreaming: true, queue: [] }); + + useChatStore.getState().sendMessage("/compact"); + + expect(boundary.setPermissionMode).not.toHaveBeenCalled(); + expect(useChatStore.getState().queue).toHaveLength(1); + }); +}); diff --git a/apps/vscode/test/session-runtime.test.ts b/apps/vscode/test/session-runtime.test.ts index deec905e..ba7317a9 100644 --- a/apps/vscode/test/session-runtime.test.ts +++ b/apps/vscode/test/session-runtime.test.ts @@ -611,6 +611,29 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", () expect(runtime.permissionMode).toBe("manual"); }); + it("still applies the mode to the engine when the cached mode already matches", async () => { + // The runtime is seeded as yolo while the engine session is still manual — + // trusting the cache here would leave the engine asking for approvals. + const { runtime, sdk } = createRuntime("yolo"); + + await runtime.setPermissionMode("yolo"); + + expect(sdk.setPermissions).toEqual(["yolo"]); + expect(runtime.permissionMode).toBe("yolo"); + }); + + it("applies a permission change while a turn is running", async () => { + const { runtime, sdk } = createRuntime(); + const completion = runtime.prompt("run something long"); + sdk.emit(turnStarted()); + + await runtime.setPermissionMode("yolo"); + expect(sdk.setPermissions).toEqual(["yolo"]); + + sdk.emit(turnEnded("completed")); + await completion; + }); + it("persists each mode change into the session metadata", async () => { const { runtime, sdk } = createRuntime(); diff --git a/apps/vscode/webview-ui/src/services/bridge.ts b/apps/vscode/webview-ui/src/services/bridge.ts index b2505e77..e7fa842e 100644 --- a/apps/vscode/webview-ui/src/services/bridge.ts +++ b/apps/vscode/webview-ui/src/services/bridge.ts @@ -216,6 +216,13 @@ class Bridge { return this.call<{ aborted: boolean }>(Methods.AbortChat); } + setPermissionMode(mode: "yolo" | "auto", request: "on" | "off" | "toggle") { + return this.call<{ ok: boolean; mode?: string; message?: string }>( + Methods.SetPermissionMode, + { mode, request }, + ); + } + resetSession() { return this.call<{ ok: boolean }>(Methods.ResetSession); } diff --git a/apps/vscode/webview-ui/src/stores/chat.store.ts b/apps/vscode/webview-ui/src/stores/chat.store.ts index de63e4d9..6825ae33 100644 --- a/apps/vscode/webview-ui/src/stores/chat.store.ts +++ b/apps/vscode/webview-ui/src/stores/chat.store.ts @@ -155,6 +155,47 @@ function clearAllInlineErrors(draft: ChatState): void { } } +interface PermissionCommand { + readonly mode: "yolo" | "auto"; + readonly request: "on" | "off" | "toggle"; +} + +/** `/yolo`, `/auto`, and `/afk`, each with an optional `on` / `off` argument. */ +function parsePermissionCommand(text: string): PermissionCommand | undefined { + const match = /^\/(yolo|auto|afk)(?:\s+(on|off))?\s*$/i.exec(text.trim()); + if (match === null) return undefined; + const name = match[1]!.toLowerCase(); + const argument = match[2]?.toLowerCase(); + return { + mode: name === "yolo" ? "yolo" : "auto", + request: argument === "on" ? "on" : argument === "off" ? "off" : "toggle", + }; +} + +/** + * Enabling a mode that auto-approves does not retract the approval the engine + * already asked for, so the requests on screen are answered here — otherwise + * the turn stays parked on the very prompt the user just turned off. + */ +async function applyPermissionCommand(command: PermissionCommand): Promise { + const result = await bridge + .setPermissionMode(command.mode, command.request) + .catch(() => undefined); + if (result === undefined || !result.ok) { + toast.error("Could not change the permission mode."); + return; + } + if (result.message) toast.success(result.message); + + if (result.mode !== "yolo" && result.mode !== "auto") return; + const approvals = useApprovalStore.getState(); + const response = result.mode === "yolo" ? "approve_for_session" : "approve"; + // `pending` is replaced on every response, so this snapshot stays stable. + for (const request of approvals.pending) { + await approvals.respondToRequest(request.id, response).catch(() => undefined); + } +} + function doSend(state: ChatState, content: string | ContentPart[], model: string) { const { sessionId, planMode } = state; const { thinkingEffort } = useSettingsStore.getState(); @@ -213,6 +254,16 @@ export const useChatStore = create((set, get) => ({ return; } + // `/yolo` and `/auto` change how the running turn behaves, so they take a + // control path: queueing them behind a turn that is itself blocked on an + // approval means the command can never arrive. + const permissionCommand = parsePermissionCommand(text); + if (permissionCommand) { + set({ draftMedia: [] }); + void applyPermissionCommand(permissionCommand); + return; + } + // If streaming, enqueue instead of sending if (isStreaming) { get().enqueue(content, currentModel);