Skip to content

Commit 540405b

Browse files
committed
fix(vscode): apply /yolo and /auto while a turn is running
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.
1 parent 407878b commit 540405b

10 files changed

Lines changed: 270 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
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.

apps/vscode/shared/bridge.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const Methods = {
4242
AbortChat: "abortChat",
4343
ResetSession: "resetSession",
4444
SetPlanMode: "setPlanMode",
45+
SetPermissionMode: "setPermissionMode",
4546
SteerChat: "steerChat",
4647
RespondApproval: "respondApproval",
4748

@@ -208,6 +209,12 @@ function validateParams(method: RpcMethod, params: unknown): boolean {
208209
&& isStringRecord(params["answers"]);
209210
case Methods.SetPlanMode:
210211
return hasBoolean(params, "enabled");
212+
case Methods.SetPermissionMode:
213+
return isPlainObject(params)
214+
&& (params["mode"] === "yolo" || params["mode"] === "auto")
215+
&& (params["request"] === "on"
216+
|| params["request"] === "off"
217+
|| params["request"] === "toggle");
211218
case Methods.SteerChat:
212219
return isPlainObject(params) && isContent(params["content"]);
213220
case Methods.GetProjectFiles:

apps/vscode/src/handlers/chat.handler.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as vscode from "vscode";
2-
import { isPythinkerError } from "@pythoughts/pythinker-code-sdk";
2+
import { isPythinkerError, type PermissionMode } from "@pythoughts/pythinker-code-sdk";
33

44
import { Events, Methods } from "../../shared/bridge";
55
import type { ApprovalResponse, ContentPart } from "../../shared/legacy-sdk";
@@ -9,7 +9,12 @@ import { VSCodeSettings } from "../config/vscode-settings";
99
import { normalizeEffort } from "../runtime/pythinker-runtime";
1010
import type { SessionRuntime } from "../runtime/session-runtime";
1111
import { isWorkspacePathContained, relativeWorkspacePath } from "../utils/workspace-path";
12-
import { parseHostSlashCommand, runHostSlashCommand } from "./slash-command";
12+
import {
13+
applyPermissionCommand,
14+
parseHostSlashCommand,
15+
runHostSlashCommand,
16+
type PermissionCommandRequest,
17+
} from "./slash-command";
1318
import type { Handler } from "./types";
1419

1520
interface StreamChatParams {
@@ -169,6 +174,21 @@ const setPlanMode: Handler<{ enabled: boolean }, { ok: boolean; planMode: boolea
169174
return { ok: true, planMode: params.enabled };
170175
};
171176

177+
/**
178+
* `/yolo` and `/auto` are control commands, not turns: the webview sends them
179+
* here instead of through the chat queue so they still take effect while the
180+
* agent is running — which is exactly when a pending approval blocks it.
181+
*/
182+
const setPermissionMode: Handler<
183+
{ mode: "yolo" | "auto"; request: PermissionCommandRequest },
184+
{ ok: boolean; mode?: PermissionMode; message?: string }
185+
> = async (params, ctx) => {
186+
const runtime = ctx.getSession();
187+
if (runtime === undefined) return { ok: false };
188+
const result = await applyPermissionCommand(runtime, params.mode, params.request);
189+
return { ok: true, mode: result.mode, message: result.message };
190+
};
191+
172192
const steerChat: Handler<{ content: string | ContentPart[] }, { ok: boolean }> = async (params, ctx) => {
173193
const runtime = ctx.getSession();
174194
if (runtime === undefined || !runtime.isBusy) return { ok: false };
@@ -190,6 +210,7 @@ export const chatHandlers: Record<string, Handler<any, any>> = {
190210
[Methods.RespondApproval]: respondApproval,
191211
[Methods.RespondQuestion]: respondQuestion,
192212
[Methods.SetPlanMode]: setPlanMode,
213+
[Methods.SetPermissionMode]: setPermissionMode,
193214
[Methods.SteerChat]: steerChat,
194215
[Methods.ResetSession]: resetSession,
195216
};

apps/vscode/src/handlers/slash-command.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,34 @@ const PERMISSION_MODE_DISABLED_MESSAGE = {
154154
auto: "Auto mode disabled. You are back at the keyboard.",
155155
} as const;
156156

157-
/** `/yolo` and `/auto` accept `on` and `off`, and toggle without an argument — as the CLI does. */
158-
async function runPermissionCommand(
157+
/** `on`, `off`, or a bare toggle — the argument forms `/yolo` and `/auto` accept. */
158+
export type PermissionCommandRequest = "on" | "off" | "toggle";
159+
160+
export function parsePermissionCommandRequest(args: string): PermissionCommandRequest {
161+
const subcommand = args.trim().toLowerCase();
162+
if (subcommand === "on") return "on";
163+
if (subcommand === "off") return "off";
164+
return "toggle";
165+
}
166+
167+
/**
168+
* Applies a `/yolo` or `/auto` request and reports the resulting mode. Callers
169+
* own how the message is surfaced, so this runs identically whether the command
170+
* came in between turns or mid-turn over the bridge.
171+
*/
172+
export async function applyPermissionCommand(
159173
runtime: SessionRuntime,
160174
mode: "yolo" | "auto",
161-
args: string,
162-
emit: (text: string) => void,
163-
): Promise<void> {
164-
const subcommand = args.trim().toLowerCase();
165-
const requested =
166-
subcommand === "on" ? mode : subcommand === "off" ? "manual" : undefined;
175+
request: PermissionCommandRequest,
176+
): Promise<{ mode: PermissionMode; message: string }> {
177+
const requested = request === "on" ? mode : request === "off" ? "manual" : undefined;
167178

168179
if (requested !== undefined && runtime.permissionMode === requested) {
169-
emit(requested === mode ? `${label(mode)} is already on.` : `${label(mode)} is already off.`);
170-
return;
180+
return {
181+
mode: requested,
182+
message:
183+
requested === mode ? `${label(mode)} is already on.` : `${label(mode)} is already off.`,
184+
};
171185
}
172186

173187
let current: PermissionMode;
@@ -178,11 +192,28 @@ async function runPermissionCommand(
178192
current = requested;
179193
}
180194

181-
emit(
182-
current === mode
183-
? PERMISSION_MODE_ENABLED_MESSAGE[mode]
184-
: PERMISSION_MODE_DISABLED_MESSAGE[mode],
195+
return {
196+
mode: current,
197+
message:
198+
current === mode
199+
? PERMISSION_MODE_ENABLED_MESSAGE[mode]
200+
: PERMISSION_MODE_DISABLED_MESSAGE[mode],
201+
};
202+
}
203+
204+
/** `/yolo` and `/auto` accept `on` and `off`, and toggle without an argument — as the CLI does. */
205+
async function runPermissionCommand(
206+
runtime: SessionRuntime,
207+
mode: "yolo" | "auto",
208+
args: string,
209+
emit: (text: string) => void,
210+
): Promise<void> {
211+
const result = await applyPermissionCommand(
212+
runtime,
213+
mode,
214+
parsePermissionCommandRequest(args),
185215
);
216+
emit(result.message);
186217
}
187218

188219
function label(mode: "yolo" | "auto"): string {

apps/vscode/src/runtime/session-runtime.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,19 @@ export class SessionRuntime {
131131
return next;
132132
}
133133

134+
/**
135+
* Always reconciles against the engine rather than trusting the cached mode:
136+
* a cache that drifted would otherwise report the mode as already set and
137+
* never call through.
138+
*/
134139
async setPermissionMode(mode: PermissionMode): Promise<void> {
135-
if (this.currentPermissionMode === mode) return;
136140
this.ensureOpen();
137141
const status = await this.session.getStatus();
138142
if (status.permission !== mode) await this.session.setPermission(mode);
139-
await persistPermissionMode(this.session, mode);
140-
this.currentPermissionMode = mode;
143+
if (this.currentPermissionMode !== mode) {
144+
await persistPermissionMode(this.session, mode);
145+
this.currentPermissionMode = mode;
146+
}
141147
}
142148

143149
subscribe(webviewId: string): void {

apps/vscode/test/bridge-handler.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,37 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () =>
149149
expect(cancel).toHaveBeenCalledOnce();
150150
});
151151

152+
it("changes the permission mode of a running session without starting a turn", async () => {
153+
const setPermissionMode = vi.fn(async () => undefined);
154+
vi.spyOn(bridge.runtime, "getSessionForView").mockReturnValue({
155+
permissionMode: "manual",
156+
setPermissionMode,
157+
beginHostAction: () => {
158+
throw new Error("A host action must not frame a permission change");
159+
},
160+
} as never);
161+
162+
const result = await bridge.handle(
163+
{ id: "rpc-1", method: Methods.SetPermissionMode, params: { mode: "yolo", request: "on" } },
164+
"view-1",
165+
);
166+
167+
expect(setPermissionMode).toHaveBeenCalledWith("yolo");
168+
expect(result).toMatchObject({ id: "rpc-1", result: { ok: true, mode: "yolo" } });
169+
});
170+
171+
it("rejects a permission change for a mode it does not control", async () => {
172+
const result = await bridge.handle(
173+
{ id: "rpc-1", method: Methods.SetPermissionMode, params: { mode: "plan", request: "on" } },
174+
"view-1",
175+
);
176+
177+
expect(result).toEqual({
178+
id: "rpc-1",
179+
error: "Invalid bridge params for method: setPermissionMode",
180+
});
181+
});
182+
152183
it.each(["missingMethod", "toString", "constructor", "__proto__"])(
153184
"does not dispatch the unknown or prototype method %s",
154185
async (method) => {

apps/vscode/test/event-handlers.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
import { beforeEach, describe, expect, it, vi } from "vitest";
99
import { useChatStore } from "../webview-ui/src/stores/chat.store";
10+
import { useApprovalStore } from "../webview-ui/src/stores/approval.store";
1011
import { deriveWorkflowLanes, maxLaneStepCount } from "../webview-ui/src/lib/workflow-lanes";
1112
import type { UIStepItem } from "../webview-ui/src/stores/chat.store";
1213

@@ -17,6 +18,9 @@ const boundary = vi.hoisted(() => ({
1718
trackFiles: vi.fn(),
1819
toastError: vi.fn(),
1920
toastWarning: vi.fn(),
21+
toastSuccess: vi.fn(),
22+
setPermissionMode: vi.fn(),
23+
respondApproval: vi.fn(),
2024
}));
2125

2226
vi.mock("@/services", () => ({
@@ -25,10 +29,16 @@ vi.mock("@/services", () => ({
2529
streamChat: boundary.streamChat,
2630
abortChat: boundary.abortChat,
2731
trackFiles: boundary.trackFiles,
32+
setPermissionMode: boundary.setPermissionMode,
33+
respondApproval: boundary.respondApproval,
2834
},
2935
}));
3036
vi.mock("@/components/ui/sonner", () => ({
31-
toast: { error: boundary.toastError, warning: boundary.toastWarning },
37+
toast: {
38+
error: boundary.toastError,
39+
warning: boundary.toastWarning,
40+
success: boundary.toastSuccess,
41+
},
3242
}));
3343

3444
beforeEach(() => {
@@ -270,3 +280,61 @@ describe("workflow lane derivation", () => {
270280
expect(maxLaneStepCount(lanes)).toBe(2);
271281
});
272282
});
283+
284+
describe("permission slash commands (control path, not a queued turn)", () => {
285+
beforeEach(() => {
286+
boundary.setPermissionMode.mockReset();
287+
boundary.respondApproval.mockReset();
288+
boundary.toastSuccess.mockReset();
289+
boundary.respondApproval.mockResolvedValue({ ok: true });
290+
useApprovalStore.setState({ pending: [] });
291+
});
292+
293+
it("sends /yolo straight through while a turn is streaming instead of queueing it", async () => {
294+
boundary.setPermissionMode.mockResolvedValue({
295+
ok: true,
296+
mode: "yolo",
297+
message: "You only live once!",
298+
});
299+
useChatStore.setState({ isStreaming: true, queue: [] });
300+
301+
useChatStore.getState().sendMessage("/yolo");
302+
await vi.waitFor(() => expect(boundary.setPermissionMode).toHaveBeenCalled());
303+
304+
expect(boundary.setPermissionMode).toHaveBeenCalledWith("yolo", "toggle");
305+
expect(useChatStore.getState().queue).toHaveLength(0);
306+
expect(boundary.streamChat).not.toHaveBeenCalled();
307+
});
308+
309+
it("answers the approvals already on screen when a turn is unblocked by /yolo", async () => {
310+
boundary.setPermissionMode.mockResolvedValue({ ok: true, mode: "yolo", message: "on" });
311+
useApprovalStore.setState({
312+
pending: [
313+
{
314+
id: "approval-1",
315+
tool_call_id: "call-1",
316+
sender: "Bash",
317+
action: "run",
318+
description: "grep",
319+
display: [],
320+
},
321+
],
322+
});
323+
useChatStore.setState({ isStreaming: true, queue: [] });
324+
325+
useChatStore.getState().sendMessage("/yolo on");
326+
await vi.waitFor(() => expect(boundary.respondApproval).toHaveBeenCalled());
327+
328+
expect(boundary.respondApproval).toHaveBeenCalledWith("approval-1", "approve_for_session");
329+
expect(useApprovalStore.getState().pending).toHaveLength(0);
330+
});
331+
332+
it("still queues an ordinary message while streaming", () => {
333+
useChatStore.setState({ isStreaming: true, queue: [] });
334+
335+
useChatStore.getState().sendMessage("/compact");
336+
337+
expect(boundary.setPermissionMode).not.toHaveBeenCalled();
338+
expect(useChatStore.getState().queue).toHaveLength(1);
339+
});
340+
});

apps/vscode/test/session-runtime.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,29 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", ()
611611
expect(runtime.permissionMode).toBe("manual");
612612
});
613613

614+
it("still applies the mode to the engine when the cached mode already matches", async () => {
615+
// The runtime is seeded as yolo while the engine session is still manual —
616+
// trusting the cache here would leave the engine asking for approvals.
617+
const { runtime, sdk } = createRuntime("yolo");
618+
619+
await runtime.setPermissionMode("yolo");
620+
621+
expect(sdk.setPermissions).toEqual(["yolo"]);
622+
expect(runtime.permissionMode).toBe("yolo");
623+
});
624+
625+
it("applies a permission change while a turn is running", async () => {
626+
const { runtime, sdk } = createRuntime();
627+
const completion = runtime.prompt("run something long");
628+
sdk.emit(turnStarted());
629+
630+
await runtime.setPermissionMode("yolo");
631+
expect(sdk.setPermissions).toEqual(["yolo"]);
632+
633+
sdk.emit(turnEnded("completed"));
634+
await completion;
635+
});
636+
614637
it("persists each mode change into the session metadata", async () => {
615638
const { runtime, sdk } = createRuntime();
616639

apps/vscode/webview-ui/src/services/bridge.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,13 @@ class Bridge {
216216
return this.call<{ aborted: boolean }>(Methods.AbortChat);
217217
}
218218

219+
setPermissionMode(mode: "yolo" | "auto", request: "on" | "off" | "toggle") {
220+
return this.call<{ ok: boolean; mode?: string; message?: string }>(
221+
Methods.SetPermissionMode,
222+
{ mode, request },
223+
);
224+
}
225+
219226
resetSession() {
220227
return this.call<{ ok: boolean }>(Methods.ResetSession);
221228
}

0 commit comments

Comments
 (0)