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);