Skip to content

Commit 649ec69

Browse files
authored
fix(vscode): apply /yolo and /auto while a turn is running (#25)
## Related Issue No issue filed — the problem is described below. ## Problem In the VS Code extension, typing `/yolo` while the agent is running does nothing. The command sits in the composer queue and the approval prompt keeps blocking the turn — which is precisely the moment a user reaches for it. Two independent causes: 1. The webview queues **every** input while `isStreaming` (`chat.store.ts`). A turn parked on an approval never ends, so a queued `/yolo` can never run. The deadlock is complete: the command that would stop the prompts is waiting for the prompts to stop. 2. Even sent immediately, the host frames every slash command as a turn (`beginHostAction`), which throws `ALREADY_GENERATING` while busy. The CLI has neither problem: `/yolo` and `/auto` are `availability: 'always'` in the TUI registry and run mid-stream. ## What changed - `/yolo`, `/auto`, and `/afk` take a control path: a new `setPermissionMode` bridge method that changes the mode directly, with no turn framing — the same shape as the existing `setPlanMode`. Every other slash command keeps queueing. - Switching to yolo or auto answers the approval requests already on screen (`approve_for_session` for yolo, `approve` for auto), because the engine asked for them before the mode changed and would otherwise stay parked on them. This mirrors the web UI, which already auto-approves pending requests on the same transition. - `SessionRuntime.setPermissionMode` no longer early-returns when the cached mode matches; it always reconciles against the engine. A drifted cache previously reported "already on" and never called through — a second path to the same symptom. Deliberately out of scope: `StatusUpdate` still carries no permission mode, so the webview has no persistent yolo/auto indicator. That gap is real but separate. Tests: 3 store tests (control path, pending-approval answering, ordinary messages still queue), 2 runtime tests (mid-turn change, cache-drift reconciliation), 2 bridge tests (dispatch without host action, param validation). Verified each fails against the old behavior. `pnpm test` in `apps/vscode`: 317 passed. `pnpm typecheck` clean. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update.
1 parent ae01098 commit 649ec69

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)