diff --git a/.changeset/web-plan-approval-yolo.md b/.changeset/web-plan-approval-yolo.md new file mode 100644 index 000000000..f94c01b28 --- /dev/null +++ b/.changeset/web-plan-approval-yolo.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Keep web plan approval prompts from being auto-approved in YOLO mode. diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index b9b0bbd58..48b026dcb 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -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, @@ -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); + return false; +} + /** Build ApprovalBlock from AppApprovalRequest (discriminated union) */ function buildApprovalBlock(a: AppApprovalRequest): ApprovalBlock { // Cast display to a loose dict for defensive reading @@ -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, diff --git a/apps/kimi-web/test/useKimiWebClient-session-cache.test.ts b/apps/kimi-web/test/useKimiWebClient-session-cache.test.ts index 3031bd32d..35d9abd0e 100644 --- a/apps/kimi-web/test/useKimiWebClient-session-cache.test.ts +++ b/apps/kimi-web/test/useKimiWebClient-session-cache.test.ts @@ -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 () => ({ @@ -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) => ({ ...initialConfig, @@ -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([]);