Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vscode-permission-command-mid-turn.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions apps/vscode/shared/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const Methods = {
AbortChat: "abortChat",
ResetSession: "resetSession",
SetPlanMode: "setPlanMode",
SetPermissionMode: "setPermissionMode",
SteerChat: "steerChat",
RespondApproval: "respondApproval",

Expand Down Expand Up @@ -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:
Expand Down
25 changes: 23 additions & 2 deletions apps/vscode/src/handlers/chat.handler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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 };
Expand All @@ -190,6 +210,7 @@ export const chatHandlers: Record<string, Handler<any, any>> = {
[Methods.RespondApproval]: respondApproval,
[Methods.RespondQuestion]: respondQuestion,
[Methods.SetPlanMode]: setPlanMode,
[Methods.SetPermissionMode]: setPermissionMode,
[Methods.SteerChat]: steerChat,
[Methods.ResetSession]: resetSession,
};
Expand Down
59 changes: 45 additions & 14 deletions apps/vscode/src/handlers/slash-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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;
Expand All @@ -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<void> {
const result = await applyPermissionCommand(
runtime,
mode,
parsePermissionCommandRequest(args),
);
emit(result.message);
}

function label(mode: "yolo" | "auto"): string {
Expand Down
12 changes: 9 additions & 3 deletions apps/vscode/src/runtime/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 {
Expand Down
31 changes: 31 additions & 0 deletions apps/vscode/test/bridge-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
70 changes: 69 additions & 1 deletion apps/vscode/test/event-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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", () => ({
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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);
});
});
23 changes: 23 additions & 0 deletions apps/vscode/test/session-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
7 changes: 7 additions & 0 deletions apps/vscode/webview-ui/src/services/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading
Loading