Skip to content
Open
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/web-plan-approval-yolo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep web plan approval prompts from being auto-approved in YOLO mode.
28 changes: 24 additions & 4 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,11 +1001,12 @@ function connectEventsIfNeeded(): void {
}

// Permission auto-approve: CLIENT-SIDE POLICY until the daemon exposes a
// permission endpoint. When permission is 'auto' or 'yolo' and an approval
// request arrives, immediately respond with 'approved'.
// permission endpoint. Auto mode approves all approval requests. YOLO
// approves regular requests, but plan review still needs explicit user
// approval, matching the TUI.
if (appEvent.type === 'approvalRequested') {
const perm = rawState.permission;
if (perm === 'auto' || perm === 'yolo') {
if (shouldAutoApproveApproval(appEvent.approval, perm)) {
void respondApproval(appEvent.approval.approvalId, {
decision: 'approved',
scope: perm === 'yolo' ? 'session' : undefined,
Expand Down Expand Up @@ -1626,6 +1627,23 @@ function buildDiffLines(oldText: string, newText: string): DiffLine[] {
return lines;
}

function approvalDisplayKind(approval: AppApprovalRequest): string | undefined {
const display = approval.display;
if (display === null || typeof display !== 'object') return undefined;
const kind = (display as { kind?: unknown }).kind;
return typeof kind === 'string' ? kind : undefined;
}

function isPlanReviewApproval(approval: AppApprovalRequest): boolean {
return approval.toolName === 'ExitPlanMode' || approvalDisplayKind(approval) === 'plan_review';
}

function shouldAutoApproveApproval(approval: AppApprovalRequest, mode: PermissionMode): boolean {
if (mode === 'auto') return true;
if (mode === 'yolo') return !isPlanReviewApproval(approval);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent session-scoped approvals for YOLO plan reviews

When a YOLO ExitPlanMode/plan_review request reaches this branch, it is no longer auto-approved and is rendered through the existing generic ApprovalCard. That card always offers “Approve for this session” and respondApproval forwards scope: 'session'; choosing it records a session-scoped ExitPlanMode approval, so later plan exits in the same session can bypass explicit review instead of using the TUI-style plan-review choices. Please add a plan-review-specific pending approval path or suppress the session-scoped action for these requests.

Useful? React with 👍 / 👎.

return false;
}

/** Build ApprovalBlock from AppApprovalRequest (discriminated union) */
function buildApprovalBlock(a: AppApprovalRequest): ApprovalBlock {
// Cast display to a loose dict for defensive reading
Expand Down Expand Up @@ -3733,12 +3751,14 @@ function setPermission(mode: PermissionMode): void {
savePermissionToStorage(mode);
persistSessionProfile({ permissionMode: mode });

// If switching to auto/yolo, auto-approve any currently-pending approvals for the active session
// If switching to auto/yolo, auto-approve pending approvals for the active
// session. In YOLO mode, keep plan review approvals pending for the user.
if (mode === 'auto' || mode === 'yolo') {
const sid = rawState.activeSessionId;
if (sid) {
const approvals = [...(rawState.approvalsBySession[sid] ?? [])];
for (const a of approvals) {
if (!shouldAutoApproveApproval(a, mode)) continue;
void respondApproval(a.approvalId, {
decision: 'approved',
scope: mode === 'yolo' ? 'session' : undefined,
Expand Down
88 changes: 88 additions & 0 deletions apps/kimi-web/test/useKimiWebClient-session-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ async function setup(messages: AppMessage[] = []) {
pendingQuestions: [],
})),
submitPrompt: vi.fn(async () => ({ promptId: 'pr_1', userMessageId: 'msg_real' })),
respondApproval: vi.fn(async () => ({ resolved: true, resolvedAt: now })),
listTasks: vi.fn(async () => []),
getGitStatus: vi.fn(async () => ({ branch: 'main', ahead: 0, behind: 0, entries: {}, additions: 0, deletions: 0 })),
getSessionStatus: vi.fn(async () => ({
Expand All @@ -97,6 +98,7 @@ async function setup(messages: AppMessage[] = []) {
maxContextTokens: 128_000,
contextUsage: 0,
})),
updateSession: vi.fn(async () => created),
getConfig: vi.fn(async () => initialConfig),
setConfig: vi.fn(async (patch: Partial<AppConfig>) => ({
...initialConfig,
Expand Down Expand Up @@ -322,6 +324,92 @@ describe('useKimiWebClient session memory cache', () => {
expect(eventConn.bindNextPromptId).toHaveBeenCalledWith('sess_1', 'pr_1');
});

it('does not auto-approve plan review approvals in yolo mode', async () => {
const { api, client, getHandlers } = await setup([]);
await client.createSession('/repo');
client.setPermission('yolo');

getHandlers().onEvent(
{
type: 'approvalRequested',
sessionId: 'sess_1',
approval: {
approvalId: 'ap_bash',
sessionId: 'sess_1',
toolCallId: 'call_bash',
toolName: 'Bash',
action: 'Run command',
display: { kind: 'shell', command: 'echo ok' },
expiresAt: now,
createdAt: now,
},
},
{ sessionId: 'sess_1', seq: 8 },
);

expect(api.respondApproval).toHaveBeenCalledWith('sess_1', 'ap_bash', {
decision: 'approved',
scope: 'session',
feedback: undefined,
});
await Promise.resolve();

getHandlers().onEvent(
{
type: 'approvalRequested',
sessionId: 'sess_1',
approval: {
approvalId: 'ap_plan',
sessionId: 'sess_1',
toolCallId: 'call_plan',
toolName: 'ExitPlanMode',
action: 'Review plan',
display: { kind: 'plan_review', plan: 'Ship it' },
expiresAt: now,
createdAt: now,
},
},
{ sessionId: 'sess_1', seq: 9 },
);

expect(api.respondApproval).toHaveBeenCalledTimes(1);
expect(client.pendingApprovals.value).toEqual([
{
approvalId: 'ap_plan',
block: { kind: 'generic', summary: 'Review plan' },
agentName: undefined,
},
]);
});

it('does not auto-approve existing plan review approvals when switching to yolo', async () => {
const { api, client, getHandlers } = await setup([]);
await client.createSession('/repo');

getHandlers().onEvent(
{
type: 'approvalRequested',
sessionId: 'sess_1',
approval: {
approvalId: 'ap_plan',
sessionId: 'sess_1',
toolCallId: 'call_plan',
toolName: 'ExitPlanMode',
action: 'Review plan',
display: { kind: 'plan_review', plan: 'Ship it' },
expiresAt: now,
createdAt: now,
},
},
{ sessionId: 'sess_1', seq: 8 },
);

client.setPermission('yolo');

expect(api.respondApproval).not.toHaveBeenCalled();
expect(client.pendingApprovals.value).toHaveLength(1);
});

it('merges a user message echo into the optimistic turn instead of appending', async () => {
const { client, getHandlers } = await setup([]);

Expand Down