From 12d2d029fdf34e70f0b20c70d9734da7aeb080aa Mon Sep 17 00:00:00 2001 From: Xiaoyi Date: Thu, 2 Apr 2026 00:35:04 -0700 Subject: [PATCH 1/8] fix(claude): preserve exit-plan session continuity --- cli/src/claude/claudeRemoteLauncher.ts | 7 +- cli/src/claude/runClaude.ts | 3 + cli/src/claude/session.test.ts | 90 ++++++++ cli/src/claude/session.ts | 43 ++++ .../claude/utils/permissionHandler.test.ts | 200 ++++++++++++++++++ cli/src/claude/utils/permissionHandler.ts | 78 ++++++- .../permission/BasePermissionHandler.ts | 4 +- cli/src/utils/MessageQueue2.test.ts | 23 +- cli/src/utils/MessageQueue2.ts | 33 +++ hub/src/sync/rpcGateway.ts | 6 +- hub/src/sync/syncEngine.ts | 7 +- hub/src/web/routes/permissions.test.ts | 184 ++++++++++++++++ hub/src/web/routes/permissions.ts | 35 ++- shared/src/modes.ts | 3 + shared/src/schemas.ts | 4 +- shared/src/types.ts | 1 + web/src/api/client.ts | 2 + web/src/chat/normalizeAgent.ts | 2 + web/src/chat/reconcile.ts | 1 + web/src/chat/reducerTimeline.test.ts | 51 +++++ web/src/chat/reducerTimeline.ts | 3 + web/src/chat/reducerTools.ts | 1 + web/src/chat/types.ts | 2 + .../ToolCard/ExitPlanModeFooter.test.tsx | 122 +++++++++++ .../ToolCard/ExitPlanModeFooter.tsx | 190 +++++++++++++++++ web/src/components/ToolCard/ToolCard.tsx | 93 +++++--- web/src/components/ToolCard/exitPlanMode.ts | 41 ++++ web/src/components/ToolCard/knownTools.tsx | 4 +- .../ToolCard/views/ExitPlanModeView.tsx | 49 ++++- web/src/lib/locales/en.ts | 10 + web/src/lib/locales/zh-CN.ts | 10 + web/src/test/setup.ts | 6 + web/src/types/api.ts | 1 + 33 files changed, 1249 insertions(+), 60 deletions(-) create mode 100644 cli/src/claude/session.test.ts create mode 100644 hub/src/web/routes/permissions.test.ts create mode 100644 web/src/components/ToolCard/ExitPlanModeFooter.test.tsx create mode 100644 web/src/components/ToolCard/ExitPlanModeFooter.tsx create mode 100644 web/src/components/ToolCard/exitPlanMode.ts diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index c5f4a327f..513166ba7 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -11,7 +11,7 @@ import { SDKToLogConverter } from "./utils/sdkToLogConverter"; import { PLAN_FAKE_REJECT } from "./sdk/prompts"; import { EnhancedMode } from "./loop"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; -import type { ClaudePermissionMode } from "@hapi/protocol/types"; +import type { ClaudePermissionMode, ExitPlanImplementationMode } from "@hapi/protocol/types"; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, @@ -22,6 +22,7 @@ interface PermissionsField { date: number; result: 'approved' | 'denied'; mode?: ClaudePermissionMode; + implementationMode?: ExitPlanImplementationMode; allowedTools?: string[]; } @@ -210,6 +211,10 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { permissions.mode = response.mode; } + if (response.implementationMode) { + permissions.implementationMode = response.implementationMode; + } + if (response.allowTools && response.allowTools.length > 0) { permissions.allowedTools = response.allowTools; } diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index cdc62fe0b..caba90fa3 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -256,6 +256,7 @@ export async function runClaude(options: StartOptions = {}): Promise { allowedTools: messageAllowedTools, disallowedTools: messageDisallowedTools }; + currentSessionRef.current?.setModeSnapshot(enhancedMode); // Use raw text only, ignore attachments for special commands const commandText = specialCommand.originalMessage || message.content.text; messageQueue.pushIsolateAndClear(commandText, enhancedMode); @@ -275,6 +276,7 @@ export async function runClaude(options: StartOptions = {}): Promise { allowedTools: messageAllowedTools, disallowedTools: messageDisallowedTools }; + currentSessionRef.current?.setModeSnapshot(enhancedMode); // Use raw text only, ignore attachments for special commands const commandText = specialCommand.originalMessage || message.content.text; messageQueue.pushIsolateAndClear(commandText, enhancedMode); @@ -293,6 +295,7 @@ export async function runClaude(options: StartOptions = {}): Promise { allowedTools: messageAllowedTools, disallowedTools: messageDisallowedTools }; + currentSessionRef.current?.setModeSnapshot(enhancedMode); messageQueue.push(formattedText, enhancedMode); logger.debugLargeJson('User message pushed to queue:', message) }); diff --git a/cli/src/claude/session.test.ts b/cli/src/claude/session.test.ts new file mode 100644 index 000000000..8f41e8b61 --- /dev/null +++ b/cli/src/claude/session.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { Session } from './session'; +import type { EnhancedMode } from './loop'; + +describe('Claude Session.clearSessionId', () => { + it('removes the persisted Claude session token from metadata', () => { + let clearedMetadata: Record | null = null; + + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn((handler: (metadata: Record) => Record) => { + clearedMetadata = handler({ + path: '/tmp/project', + claudeSessionId: 'claude-session-123' + }); + }), + rpcHandlerManager: {} + }; + + const session = new Session({ + api: {} as never, + client: client as never, + path: '/tmp/project', + logPath: '/tmp/project/hapi.log', + sessionId: 'claude-session-123', + mcpServers: {}, + messageQueue: new MessageQueue2((mode) => JSON.stringify(mode)), + onModeChange: () => {}, + startedBy: 'terminal', + startingMode: 'remote', + hookSettingsPath: '/tmp/hooks.json', + permissionMode: 'default' + }); + + try { + session.clearSessionId(); + + expect(session.sessionId).toBeNull(); + expect(client.updateMetadata).toHaveBeenCalledTimes(1); + expect(clearedMetadata).toEqual({ + path: '/tmp/project' + }); + } finally { + session.stopKeepAlive(); + } + }); + + it('removes stale persisted Claude session metadata even if the in-memory session id is already null', () => { + let clearedMetadata: Record | null = null; + + const client = { + keepAlive: vi.fn(), + updateMetadata: vi.fn((handler: (metadata: Record) => Record) => { + clearedMetadata = handler({ + path: '/tmp/project', + claudeSessionId: 'stale-claude-session' + }); + }), + rpcHandlerManager: {} + }; + + const session = new Session({ + api: {} as never, + client: client as never, + path: '/tmp/project', + logPath: '/tmp/project/hapi.log', + sessionId: null, + mcpServers: {}, + messageQueue: new MessageQueue2((mode) => JSON.stringify(mode)), + onModeChange: () => {}, + startedBy: 'terminal', + startingMode: 'remote', + hookSettingsPath: '/tmp/hooks.json', + permissionMode: 'default' + }); + + try { + session.clearSessionId(); + + expect(session.sessionId).toBeNull(); + expect(client.updateMetadata).toHaveBeenCalledTimes(1); + expect(clearedMetadata).toEqual({ + path: '/tmp/project' + }); + } finally { + session.stopKeepAlive(); + } + }); +}); diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 975bcb2da..1e0c78679 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -21,6 +21,7 @@ export class Session extends AgentSessionBase { readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; + private currentModeSnapshot: EnhancedMode; constructor(opts: { api: ApiClient; @@ -72,20 +73,54 @@ export class Session extends AgentSessionBase { this.permissionMode = opts.permissionMode; this.model = opts.model; this.effort = opts.effort; + this.currentModeSnapshot = { + permissionMode: opts.permissionMode ?? 'default', + model: opts.model ?? undefined, + effort: opts.effort ?? undefined + }; } setPermissionMode = (mode: PermissionMode): void => { this.permissionMode = mode; + this.currentModeSnapshot = { + ...this.currentModeSnapshot, + permissionMode: mode + }; }; setModel = (model: SessionModel): void => { this.model = model; + this.currentModeSnapshot = { + ...this.currentModeSnapshot, + model: model ?? undefined + }; }; setEffort = (effort: SessionEffort): void => { this.effort = effort; + this.currentModeSnapshot = { + ...this.currentModeSnapshot, + effort: effort ?? undefined + }; }; + setModeSnapshot = (mode: EnhancedMode): void => { + this.currentModeSnapshot = { + ...mode, + allowedTools: mode.allowedTools ? [...mode.allowedTools] : undefined, + disallowedTools: mode.disallowedTools ? [...mode.disallowedTools] : undefined + }; + this.permissionMode = mode.permissionMode; + this.model = mode.model ?? null; + this.effort = mode.effort ?? null; + }; + + getModeSnapshot = (): EnhancedMode => ({ + ...this.currentModeSnapshot, + allowedTools: this.currentModeSnapshot.allowedTools ? [...this.currentModeSnapshot.allowedTools] : undefined, + disallowedTools: this.currentModeSnapshot.disallowedTools ? [...this.currentModeSnapshot.disallowedTools] : undefined + }); + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; @@ -95,6 +130,14 @@ export class Session extends AgentSessionBase { */ clearSessionId = (): void => { this.sessionId = null; + this.client.updateMetadata((metadata) => { + if (metadata.claudeSessionId === undefined) { + return metadata; + } + + const { claudeSessionId: _removed, ...rest } = metadata; + return rest; + }); logger.debug('[Session] Session ID cleared'); }; diff --git a/cli/src/claude/utils/permissionHandler.test.ts b/cli/src/claude/utils/permissionHandler.test.ts index 062de88b3..4bff0eed2 100644 --- a/cli/src/claude/utils/permissionHandler.test.ts +++ b/cli/src/claude/utils/permissionHandler.test.ts @@ -106,3 +106,203 @@ describe('PermissionHandler — YOLO plan mode', () => { expect(queueItems).toHaveLength(0); }); }); + +type FakeAgentState = { + requests?: Record; + completedRequests?: Record; +}; + +function createSessionStub() { + const rpcHandlers = new Map Promise | unknown>(); + let agentState: FakeAgentState = { + requests: {}, + completedRequests: {} + }; + + const session = { + queue: { + unshiftIsolate: vi.fn() + }, + clearSessionId: vi.fn(), + getModeSnapshot: vi.fn(() => ({ + permissionMode: 'plan', + model: 'sonnet', + effort: 'high', + appendSystemPrompt: 'current append prompt' + })), + setPermissionMode: vi.fn(), + client: { + rpcHandlerManager: { + registerHandler(method: string, handler: (params: unknown) => Promise | unknown) { + rpcHandlers.set(method, handler); + } + }, + updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) { + agentState = handler(agentState); + } + } + }; + + return { + session, + rpcHandlers, + getAgentState: () => agentState + }; +} + +describe('PermissionHandler exit_plan_mode', () => { + it('defaults to keep_context and preserves the full mode snapshot when restarting', async () => { + const { session, rpcHandlers, getAgentState } = createSessionStub(); + const permissionHandler = new PermissionHandler(session as never); + + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-exit-plan', + name: 'exit_plan_mode', + input: { plan: 'Implement the approved plan' } + }] + } + } as never); + + const toolCall = permissionHandler.handleToolCall( + 'exit_plan_mode', + { plan: 'Implement the approved plan' }, + { permissionMode: 'plan' } as never, + { signal: new AbortController().signal } + ); + + const permissionRpc = rpcHandlers.get('permission'); + expect(permissionRpc).toBeTypeOf('function'); + + await permissionRpc?.({ + id: 'tool-exit-plan', + approved: true + }); + + await expect(toolCall).resolves.toEqual({ + behavior: 'deny', + message: PLAN_FAKE_REJECT + }); + + expect(session.clearSessionId).not.toHaveBeenCalled(); + expect(session.queue.unshiftIsolate).toHaveBeenCalledWith(PLAN_FAKE_RESTART, { + permissionMode: 'default', + model: 'sonnet', + effort: 'high', + appendSystemPrompt: 'current append prompt' + }); + + expect(getAgentState().completedRequests).toMatchObject({ + 'tool-exit-plan': { + status: 'approved', + implementationMode: 'keep_context' + } + }); + }); + + it('clears context only when explicitly requested and requeues the approved plan for fresh-context restart', async () => { + const { session, rpcHandlers } = createSessionStub(); + const permissionHandler = new PermissionHandler(session as never); + + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-exit-plan-accept', + name: 'ExitPlanMode', + input: { plan: 'Implement with accept-edits' } + }] + } + } as never); + + const toolCall = permissionHandler.handleToolCall( + 'ExitPlanMode', + { plan: 'Implement with accept-edits' }, + { permissionMode: 'plan' } as never, + { signal: new AbortController().signal } + ); + + const permissionRpc = rpcHandlers.get('permission'); + expect(permissionRpc).toBeTypeOf('function'); + + await permissionRpc?.({ + id: 'tool-exit-plan-accept', + approved: true, + mode: 'acceptEdits', + implementationMode: 'clear_context' + }); + + await expect(toolCall).resolves.toEqual({ + behavior: 'deny', + message: PLAN_FAKE_REJECT + }); + + expect(session.clearSessionId).toHaveBeenCalledTimes(1); + expect(session.queue.unshiftIsolate).toHaveBeenCalledWith(expect.stringContaining('Implement with accept-edits'), { + permissionMode: 'acceptEdits', + model: 'sonnet', + effort: 'high', + appendSystemPrompt: 'current append prompt' + }); + }); + + it('normalizes invalid post-plan modes to default before updating session state', async () => { + const { session, rpcHandlers, getAgentState } = createSessionStub(); + const permissionHandler = new PermissionHandler(session as never); + + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-exit-plan-invalid-mode', + name: 'exit_plan_mode', + input: { plan: 'Implement safely' } + }] + } + } as never); + + const toolCall = permissionHandler.handleToolCall( + 'exit_plan_mode', + { plan: 'Implement safely' }, + { permissionMode: 'plan' } as never, + { signal: new AbortController().signal } + ); + + const permissionRpc = rpcHandlers.get('permission'); + expect(permissionRpc).toBeTypeOf('function'); + + await permissionRpc?.({ + id: 'tool-exit-plan-invalid-mode', + approved: true, + mode: 'plan' + }); + + await expect(toolCall).resolves.toEqual({ + behavior: 'deny', + message: PLAN_FAKE_REJECT + }); + + expect(session.setPermissionMode).toHaveBeenLastCalledWith('default'); + expect(session.queue.unshiftIsolate).toHaveBeenCalledWith(PLAN_FAKE_RESTART, { + permissionMode: 'default', + model: 'sonnet', + effort: 'high', + appendSystemPrompt: 'current append prompt' + }); + expect(getAgentState().completedRequests).toMatchObject({ + 'tool-exit-plan-invalid-mode': { + status: 'approved', + mode: 'default', + implementationMode: 'keep_context' + } + }); + }); +}); diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index b2f0ae887..d3fcf7ddc 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -16,6 +16,7 @@ import { EnhancedMode, PermissionMode } from "../loop"; import { getToolDescriptor } from "./getToolDescriptor"; import { delay } from "@/utils/time"; import { isObject } from "@hapi/protocol"; +import type { ExitPlanImplementationMode } from "@hapi/protocol/types"; import { BasePermissionHandler, type PendingPermissionRequest, @@ -27,12 +28,14 @@ interface PermissionResponse { approved: boolean; reason?: string; mode?: PermissionMode; + implementationMode?: ExitPlanImplementationMode; allowTools?: string[]; answers?: Record | Record; receivedAt?: number; } const PLAN_EXIT_MODES: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions']; +const DEFAULT_EXIT_PLAN_IMPLEMENTATION_MODE: ExitPlanImplementationMode = 'keep_context'; function isAskUserQuestionToolName(toolName: string): boolean { return toolName === 'AskUserQuestion' || toolName === 'ask_user_question'; @@ -136,6 +139,45 @@ function buildRequestUserInputUpdatedInput(input: unknown, answers: unknown): Re }; } +function isExitPlanImplementationMode(value: unknown): value is ExitPlanImplementationMode { + return value === 'keep_context' || value === 'clear_context'; +} + +function resolveExitPlanImplementationMode(response: PermissionResponse): ExitPlanImplementationMode { + return isExitPlanImplementationMode(response.implementationMode) + ? response.implementationMode + : DEFAULT_EXIT_PLAN_IMPLEMENTATION_MODE; +} + +function getExitPlanRestartPermissionMode(response: PermissionResponse): PermissionMode { + return response.mode && PLAN_EXIT_MODES.includes(response.mode) + ? response.mode + : 'default'; +} + +function buildExitPlanRestartPrompt(input: unknown, implementationMode: ExitPlanImplementationMode): string { + if (implementationMode === 'keep_context') { + return PLAN_FAKE_RESTART; + } + + const plan = isObject(input) && typeof input.plan === 'string' + ? input.plan.trim() + : ''; + + if (!plan) { + return 'The user approved your plan. You are restarting in a fresh context. Begin implementation now.'; + } + + return [ + 'The user approved this implementation plan.', + 'You are restarting in a fresh context, so do not rely on prior conversation state.', + 'Implement the approved plan below immediately.', + '', + 'Approved plan:', + plan + ].join('\n'); +} + export class PermissionHandler extends BasePermissionHandler { private toolCalls: { id: string, name: string, input: any, used: boolean }[] = []; private responses = new Map(); @@ -170,10 +212,12 @@ export class PermissionHandler extends BasePermissionHandler ): Promise { + const isExitPlanModeRequest = pending.toolName === 'exit_plan_mode' || pending.toolName === 'ExitPlanMode'; const completion: PermissionCompletion = { status: response.approved ? 'approved' : 'denied', reason: response.reason, mode: response.mode, + implementationMode: response.implementationMode, allowTools: response.allowTools, answers: response.answers }; @@ -193,7 +237,7 @@ export class PermissionHandler extends BasePermissionHandler( @@ -84,6 +84,7 @@ export type PermissionCompletion = { status: 'approved' | 'denied' | 'canceled'; reason?: string; mode?: string; + implementationMode?: ExitPlanImplementationMode; decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; allowTools?: string[]; answers?: Record | Record; @@ -166,6 +167,7 @@ export abstract class BasePermissionHandler { expect(batch3?.message).toBe('after-isolated'); expect(batch3?.mode.type).toBe('B'); }); -}); \ No newline at end of file + + it('should prioritize isolated messages pushed with unshiftIsolate without clearing the queue', async () => { + const queue = new MessageQueue2<{ type: string }>((mode) => mode.type); + + queue.push('message1', { type: 'A' }); + queue.push('message2', { type: 'A' }); + queue.unshiftIsolate('isolated', { type: 'B' }); + + const batch1 = await queue.waitForMessagesAndGetAsString(); + expect(batch1).toEqual({ + message: 'isolated', + mode: { type: 'B' }, + hash: 'B', + isolate: true + }); + + const batch2 = await queue.waitForMessagesAndGetAsString(); + expect(batch2?.message).toBe('message1\nmessage2'); + expect(batch2?.mode.type).toBe('A'); + expect(batch2?.isolate).toBe(false); + }); +}); diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts index 6ba5fcdd1..e325018cd 100644 --- a/cli/src/utils/MessageQueue2.ts +++ b/cli/src/utils/MessageQueue2.ts @@ -176,6 +176,39 @@ export class MessageQueue2 { logger.debug(`[MessageQueue2] unshift() completed. Queue size: ${this.queue.length}`); } + /** + * Push a message to the beginning of the queue and force it to be processed alone. + * Unlike pushIsolateAndClear, preserves the rest of the queue. + */ + unshiftIsolate(message: string, mode: T): void { + if (this.closed) { + throw new Error('Cannot unshift to closed queue'); + } + + const modeHash = this.modeHasher(mode); + logger.debug(`[MessageQueue2] unshiftIsolate() called with mode hash: ${modeHash}`); + + this.queue.unshift({ + message, + mode, + modeHash, + isolate: true + }); + + if (this.onMessageHandler) { + this.onMessageHandler(message, mode); + } + + if (this.waiter) { + logger.debug(`[MessageQueue2] Notifying waiter`); + const waiter = this.waiter; + this.waiter = null; + waiter(true); + } + + logger.debug(`[MessageQueue2] unshiftIsolate() completed. Queue size: ${this.queue.length}`); + } + /** * Reset the queue - clears all messages and resets to empty state */ diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d59ff3b6d..6b6a7d543 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,4 +1,4 @@ -import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { CodexCollaborationMode, ExitPlanImplementationMode, PermissionMode } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -57,12 +57,14 @@ export class RpcGateway { mode?: PermissionMode, allowTools?: string[], decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', - answers?: Record | Record + answers?: Record | Record, + implementationMode?: ExitPlanImplementationMode ): Promise { await this.sessionRpc(sessionId, 'permission', { id: requestId, approved: true, mode, + implementationMode, allowTools, decision, answers diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6b5be2f1c..f52baeb73 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -7,7 +7,7 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import type { CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { CodexCollaborationMode, DecryptedMessage, ExitPlanImplementationMode, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -252,9 +252,10 @@ export class SyncEngine { mode?: PermissionMode, allowTools?: string[], decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', - answers?: Record | Record + answers?: Record | Record, + implementationMode?: ExitPlanImplementationMode ): Promise { - await this.rpcGateway.approvePermission(sessionId, requestId, mode, allowTools, decision, answers) + await this.rpcGateway.approvePermission(sessionId, requestId, mode, allowTools, decision, answers, implementationMode) } async denyPermission( diff --git a/hub/src/web/routes/permissions.test.ts b/hub/src/web/routes/permissions.test.ts new file mode 100644 index 000000000..59b8116c3 --- /dev/null +++ b/hub/src/web/routes/permissions.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createPermissionsRoutes } from './permissions' + +function createSession(overrides?: Partial): Session { + const base: Session = { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'claude' + }, + metadataVersion: 1, + agentState: { + controlledByUser: false, + requests: { + 'request-1': { + tool: 'exit_plan_mode', + arguments: { plan: 'Ship it' }, + createdAt: 1 + } + }, + completedRequests: {} + }, + agentStateVersion: 1, + thinking: false, + thinkingAt: 1, + model: null, + effort: null, + permissionMode: 'default' + } + + return { + ...base, + ...overrides, + metadata: overrides?.metadata === undefined ? base.metadata : overrides.metadata, + agentState: overrides?.agentState === undefined ? base.agentState : overrides.agentState + } +} + +function createApp(session: Session) { + const approveCalls: unknown[] = [] + const denyCalls: unknown[] = [] + const engine = { + resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), + approvePermission: async (...args: unknown[]) => { + approveCalls.push(args) + }, + denyPermission: async (...args: unknown[]) => { + denyCalls.push(args) + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createPermissionsRoutes(() => engine as SyncEngine)) + + return { app, approveCalls, denyCalls } +} + +describe('permissions routes', () => { + it('forwards implementationMode for exit_plan_mode approvals', async () => { + const { app, approveCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/permissions/request-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ implementationMode: 'clear_context' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(approveCalls).toEqual([ + ['session-1', 'request-1', undefined, undefined, undefined, undefined, 'clear_context'] + ]) + }) + + it('rejects plan mode as a post-plan approval mode', async () => { + const { app, approveCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/permissions/request-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'plan' }) + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Plan mode cannot be selected after exit_plan_mode approval' + }) + expect(approveCalls).toEqual([]) + }) + + it('rejects implementationMode for non-exit-plan permission requests', async () => { + const session = createSession({ + agentState: { + controlledByUser: false, + requests: { + 'request-1': { + tool: 'Edit', + arguments: { file_path: 'a.ts' }, + createdAt: 1 + } + }, + completedRequests: {} + } + }) + const { app, approveCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/permissions/request-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ implementationMode: 'keep_context' }) + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Implementation mode is only supported for exit_plan_mode' + }) + expect(approveCalls).toEqual([]) + }) + + it('rejects implementationMode for non-Claude exit_plan_mode requests', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex' + } + }) + const { app, approveCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/permissions/request-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ implementationMode: 'clear_context' }) + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Implementation mode is only supported for Claude exit_plan_mode' + }) + expect(approveCalls).toEqual([]) + }) + + it('rejects invalid approve decisions', async () => { + const { app, approveCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/permissions/request-1/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ decision: 'abort' }) + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'Invalid body' }) + expect(approveCalls).toEqual([]) + }) + + it('rejects invalid deny decisions', async () => { + const { app, denyCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/permissions/request-1/deny', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ decision: 'approved' }) + }) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'Invalid body' }) + expect(denyCalls).toEqual([]) + }) +}) diff --git a/hub/src/web/routes/permissions.ts b/hub/src/web/routes/permissions.ts index 3173ae84b..dd1692586 100644 --- a/hub/src/web/routes/permissions.ts +++ b/hub/src/web/routes/permissions.ts @@ -1,12 +1,13 @@ import { isPermissionModeAllowedForFlavor } from '@hapi/protocol' -import { PermissionModeSchema } from '@hapi/protocol/schemas' +import { ExitPlanImplementationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { Hono } from 'hono' import { z } from 'zod' import type { SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { requireSessionFromParam, requireSyncEngine } from './guards' -const decisionSchema = z.enum(['approved', 'approved_for_session', 'denied', 'abort']) +const approveDecisionSchema = z.enum(['approved', 'approved_for_session']) +const denyDecisionSchema = z.enum(['denied', 'abort']) // Flat format: Record (AskUserQuestion) // Nested format: Record (request_user_input) @@ -17,15 +18,20 @@ const answersSchema = z.union([ const approveBodySchema = z.object({ mode: PermissionModeSchema.optional(), + implementationMode: ExitPlanImplementationModeSchema.optional(), allowTools: z.array(z.string()).optional(), - decision: decisionSchema.optional(), + decision: approveDecisionSchema.optional(), answers: answersSchema.optional() }) const denyBodySchema = z.object({ - decision: decisionSchema.optional() + decision: denyDecisionSchema.optional() }) +function isExitPlanModeToolName(toolName: string): boolean { + return toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode' +} + export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -54,17 +60,34 @@ export function createPermissionsRoutes(getSyncEngine: () => SyncEngine | null): return c.json({ error: 'Request not found' }, 404) } + const request = requests[requestId] + const requestToolName = typeof request?.tool === 'string' ? request.tool : '' + const isExitPlanModeRequest = isExitPlanModeToolName(requestToolName) + + const flavor = session.metadata?.flavor ?? 'claude' + const mode = parsed.data.mode if (mode !== undefined) { - const flavor = session.metadata?.flavor ?? 'claude' if (!isPermissionModeAllowedForFlavor(mode, flavor)) { return c.json({ error: 'Invalid permission mode for session flavor' }, 400) } + if (isExitPlanModeRequest && mode === 'plan') { + return c.json({ error: 'Plan mode cannot be selected after exit_plan_mode approval' }, 400) + } + } + const implementationMode = parsed.data.implementationMode + if (implementationMode !== undefined) { + if (!isExitPlanModeRequest) { + return c.json({ error: 'Implementation mode is only supported for exit_plan_mode' }, 400) + } + if (flavor !== 'claude') { + return c.json({ error: 'Implementation mode is only supported for Claude exit_plan_mode' }, 400) + } } const allowTools = parsed.data.allowTools const decision = parsed.data.decision const answers = parsed.data.answers - await engine.approvePermission(sessionId, requestId, mode, allowTools, decision, answers) + await engine.approvePermission(sessionId, requestId, mode, allowTools, decision, answers, implementationMode) return c.json({ ok: true }) }) diff --git a/shared/src/modes.ts b/shared/src/modes.ts index d59320a9d..aaa35e74b 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -14,6 +14,9 @@ export type CodexPermissionMode = typeof CODEX_PERMISSION_MODES[number] export const CODEX_COLLABORATION_MODES = ['default', 'plan'] as const export type CodexCollaborationMode = typeof CODEX_COLLABORATION_MODES[number] +export const EXIT_PLAN_IMPLEMENTATION_MODES = ['keep_context', 'clear_context'] as const +export type ExitPlanImplementationMode = typeof EXIT_PLAN_IMPLEMENTATION_MODES[number] + export const GEMINI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number] diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 52ec83737..7dee9bfe5 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -1,8 +1,9 @@ import { z } from 'zod' -import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes' +import { CODEX_COLLABORATION_MODES, EXIT_PLAN_IMPLEMENTATION_MODES, PERMISSION_MODES } from './modes' export const PermissionModeSchema = z.enum(PERMISSION_MODES) export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES) +export const ExitPlanImplementationModeSchema = z.enum(EXIT_PLAN_IMPLEMENTATION_MODES) const MetadataSummarySchema = z.object({ text: z.string(), @@ -67,6 +68,7 @@ export const AgentStateCompletedRequestSchema = z.object({ status: z.enum(['canceled', 'denied', 'approved']), reason: z.string().optional(), mode: z.string().optional(), + implementationMode: ExitPlanImplementationModeSchema.optional(), decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), allowTools: z.array(z.string()).optional(), // Flat format: Record (AskUserQuestion) diff --git a/shared/src/types.ts b/shared/src/types.ts index 69caa1c91..011f3156f 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -25,6 +25,7 @@ export type { CodexCollaborationModeOption, CodexPermissionMode, CursorPermissionMode, + ExitPlanImplementationMode, GeminiPermissionMode, OpencodePermissionMode, PermissionMode, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 163eb206d..d6d6fe8b4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,6 +3,7 @@ import type { AuthResponse, CodexCollaborationMode, DeleteUploadResponse, + ExitPlanImplementationMode, ListDirectoryResponse, FileReadResponse, FileSearchResponse, @@ -339,6 +340,7 @@ export class ApiClient { requestId: string, modeOrOptions?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | { mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' + implementationMode?: ExitPlanImplementationMode allowTools?: string[] decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' answers?: Record | Record diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index cd9bcdb9d..a3d163b4a 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -10,6 +10,7 @@ function normalizeToolResultPermissions(value: unknown): ToolResultPermission | if (result !== 'approved' && result !== 'denied') return undefined const mode = asString(value.mode) ?? undefined + const implementationMode = asString(value.implementationMode) ?? undefined const allowedTools = Array.isArray(value.allowedTools) ? value.allowedTools.filter((tool) => typeof tool === 'string') : undefined @@ -22,6 +23,7 @@ function normalizeToolResultPermissions(value: unknown): ToolResultPermission | date, result, mode, + implementationMode, allowedTools, decision: normalizedDecision } diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 12517a2e4..fe6806662 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -68,6 +68,7 @@ function arePermissionsEqual(left?: ToolPermission, right?: ToolPermission): boo && left.status === right.status && left.reason === right.reason && left.mode === right.mode + && left.implementationMode === right.implementationMode && left.decision === right.decision && left.date === right.date && left.createdAt === right.createdAt diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts index f28550fce..4df1f715b 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { reduceTimeline } from './reducerTimeline' +import type { ToolCallBlock } from './types' import type { TracedMessage } from './tracer' function makeContext() { @@ -186,4 +187,54 @@ describe('reduceTimeline', () => { const events = blocks.filter(b => b.kind === 'agent-event') expect(events).toHaveLength(1) }) + + it('preserves permission mode and implementationMode from agent state when tool-result permissions omit them', () => { + const messages: TracedMessage[] = [{ + id: 'message-1', + localId: null, + createdAt: 2, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: 'tool-1', + content: { ok: true }, + is_error: false, + uuid: 'uuid-1', + parentUUID: null, + permissions: { + date: 2, + result: 'approved' + } + }] + }] + + const result = reduceTimeline(messages, { + permissionsById: new Map([ + ['tool-1', { + toolName: 'exit_plan_mode', + input: { plan: 'Ship it' }, + permission: { + id: 'tool-1', + status: 'approved', + mode: 'acceptEdits', + implementationMode: 'clear_context' + } + }] + ]), + groups: new Map(), + consumedGroupIds: new Set(), + titleChangesByToolUseId: new Map(), + emittedTitleChangeToolUseIds: new Set() + }) + + const block = result.blocks[0] as ToolCallBlock + expect(block.kind).toBe('tool-call') + expect(block.tool.permission).toMatchObject({ + id: 'tool-1', + status: 'approved', + mode: 'acceptEdits', + implementationMode: 'clear_context' + }) + }) }) diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index e434af976..eee858469 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -240,6 +240,7 @@ export function reduceTimeline( status: c.permissions.result === 'approved' ? 'approved' : 'denied', date: c.permissions.date, mode: c.permissions.mode, + implementationMode: c.permissions.implementationMode, allowedTools: c.permissions.allowedTools, decision: c.permissions.decision } satisfies ToolPermission) : undefined @@ -249,6 +250,8 @@ export function reduceTimeline( return { ...permissionEntry.permission, ...permissionFromResult, + mode: permissionFromResult.mode ?? permissionEntry.permission.mode, + implementationMode: permissionFromResult.implementationMode ?? permissionEntry.permission.implementationMode, allowedTools: permissionFromResult.allowedTools ?? permissionEntry.permission.allowedTools, decision: permissionFromResult.decision ?? permissionEntry.permission.decision } satisfies ToolPermission diff --git a/web/src/chat/reducerTools.ts b/web/src/chat/reducerTools.ts index 7031a5ac3..bcacc8e36 100644 --- a/web/src/chat/reducerTools.ts +++ b/web/src/chat/reducerTools.ts @@ -21,6 +21,7 @@ export function getPermissions(agentState: AgentState | null | undefined): Map | Record diff --git a/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx b/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx new file mode 100644 index 000000000..d70f2501c --- /dev/null +++ b/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { ExitPlanModeFooter } from '@/components/ToolCard/ExitPlanModeFooter' + +const haptic = { + selection: vi.fn(), + notification: vi.fn(), + impact: vi.fn() +} + +vi.mock('@/hooks/usePlatform', () => ({ + usePlatform: () => ({ + isTelegram: false, + isTouch: false, + haptic + }) +})) + +function renderWithProviders(ui: React.ReactElement) { + return render( + + {ui} + + ) +} + +function createTool() { + return { + id: 'tool-1', + name: 'exit_plan_mode', + state: 'pending' as const, + input: { plan: 'Implement the approved plan' }, + createdAt: 1, + startedAt: null, + completedAt: null, + description: null, + permission: { + id: 'request-1', + status: 'pending' as const + } + } +} + +describe('ExitPlanModeFooter', () => { + beforeEach(() => { + vi.clearAllMocks() + const localStorageMock = { + getItem: vi.fn(() => 'en'), + setItem: vi.fn(), + removeItem: vi.fn() + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) + }) + + it('submits the selected implementationMode', async () => { + const api = { + approvePermission: vi.fn(async () => {}), + denyPermission: vi.fn(async () => {}) + } + + renderWithProviders( + + ) + + fireEvent.click(screen.getByText('Clear context')) + fireEvent.click(screen.getByRole('button', { name: 'Start implementation' })) + + expect(api.approvePermission).toHaveBeenCalledWith('session-1', 'request-1', { + implementationMode: 'clear_context' + }) + }) + + it('shows a validation error before submission when no option is selected', async () => { + const api = { + approvePermission: vi.fn(async () => {}), + denyPermission: vi.fn(async () => {}) + } + + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Start implementation' })) + + expect(screen.getByText('Please choose how to start implementation.')).toBeInTheDocument() + expect(api.approvePermission).not.toHaveBeenCalled() + }) + + it('can deny the request directly', async () => { + const api = { + approvePermission: vi.fn(async () => {}), + denyPermission: vi.fn(async () => {}) + } + + renderWithProviders( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Deny' })) + + expect(api.denyPermission).toHaveBeenCalledWith('session-1', 'request-1') + }) +}) diff --git a/web/src/components/ToolCard/ExitPlanModeFooter.tsx b/web/src/components/ToolCard/ExitPlanModeFooter.tsx new file mode 100644 index 000000000..226e2e9b1 --- /dev/null +++ b/web/src/components/ToolCard/ExitPlanModeFooter.tsx @@ -0,0 +1,190 @@ +import { useEffect, useState } from 'react' +import type { ApiClient } from '@/api/client' +import type { ChatToolCall } from '@/chat/types' +import type { ExitPlanImplementationMode } from '@/types/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Spinner } from '@/components/Spinner' +import { usePlatform } from '@/hooks/usePlatform' +import { useTranslation } from '@/lib/use-translation' +import { cn } from '@/lib/utils' +import { + getExitPlanImplementationModeDescription, + getExitPlanImplementationModeLabel, + getExitPlanImplementationModes, + isExitPlanModeToolName +} from '@/components/ToolCard/exitPlanMode' + +function SelectionMark(props: { checked: boolean }) { + return ( + + {props.checked ? '●' : '○'} + + ) +} + +function OptionRow(props: { + checked: boolean + disabled: boolean + title: string + description: string + onClick: () => void +}) { + return ( + + ) +} + +export function ExitPlanModeFooter(props: { + api: ApiClient + sessionId: string + tool: ChatToolCall + disabled: boolean + onDone: () => void +}) { + const { t } = useTranslation() + const { haptic } = usePlatform() + const permission = props.tool.permission + const [selectedMode, setSelectedMode] = useState(null) + const [loading, setLoading] = useState<'approve' | 'deny' | null>(null) + const [error, setError] = useState(null) + + useEffect(() => { + setSelectedMode(null) + setLoading(null) + setError(null) + }, [props.tool.id]) + + if (!permission || permission.status !== 'pending') return null + if (!isExitPlanModeToolName(props.tool.name)) return null + + const run = async (action: () => Promise, hapticType: 'success' | 'error') => { + if (props.disabled) return + setError(null) + try { + await action() + haptic.notification(hapticType) + props.onDone() + } catch (e) { + haptic.notification('error') + setError(e instanceof Error ? e.message : t('dialog.error.default')) + } + } + + const approve = async () => { + if (loading || props.disabled) return + if (!selectedMode) { + setError(t('tool.exitPlanMode.selectOption')) + return + } + + setLoading('approve') + await run(() => props.api.approvePermission(props.sessionId, permission.id, { + implementationMode: selectedMode + }), 'success') + setLoading(null) + } + + const deny = async () => { + if (loading || props.disabled) return + setLoading('deny') + await run(() => props.api.denyPermission(props.sessionId, permission.id), 'success') + setLoading(null) + } + + return ( +
+
+
+
+ + {t('tool.exitPlanMode.badge')} + +
+
+ {t('tool.exitPlanMode.prompt')} +
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ {getExitPlanImplementationModes().map((mode) => ( + { + haptic.selection() + setSelectedMode(mode) + setError(null) + }} + /> + ))} +
+ +
+ + + +
+
+ ) +} diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index b8705c047..e8524c3ac 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -10,8 +10,10 @@ import { DiffView } from '@/components/DiffView' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { PermissionFooter } from '@/components/ToolCard/PermissionFooter' import { AskUserQuestionFooter } from '@/components/ToolCard/AskUserQuestionFooter' +import { ExitPlanModeFooter } from '@/components/ToolCard/ExitPlanModeFooter' import { RequestUserInputFooter } from '@/components/ToolCard/RequestUserInputFooter' import { isAskUserQuestionToolName } from '@/components/ToolCard/askUserQuestion' +import { isExitPlanModeToolName } from '@/components/ToolCard/exitPlanMode' import { isRequestUserInputToolName } from '@/components/ToolCard/requestUserInput' import { getToolPresentation } from '@/components/ToolCard/knownTools' import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all' @@ -302,7 +304,8 @@ function ToolCardInner(props: ToolCardProps) { ]) const toolName = props.block.tool.name - const toolTitle = presentation.title + const isExitPlanMode = isExitPlanModeToolName(toolName) + const toolTitle = isExitPlanMode ? t('tool.exitPlanMode.title') : presentation.title const subtitle = presentation.subtitle ?? props.block.tool.description const taskSummary = renderTaskSummary(props.block, props.metadata) const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt @@ -313,6 +316,11 @@ function ToolCardInner(props: ToolCardProps) { const permission = props.block.tool.permission const isAskUserQuestion = isAskUserQuestionToolName(toolName) const isRequestUserInput = isRequestUserInputToolName(toolName) + const isExitPlanModeWithChoice = isExitPlanMode + && (permission?.implementationMode !== undefined + || permission?.status === 'approved' + || permission?.status === 'denied' + || permission?.status === 'canceled') const isQuestionTool = isAskUserQuestion || isRequestUserInput const showsPermissionFooter = Boolean(permission && ( permission.status === 'pending' @@ -379,6 +387,7 @@ function ToolCardInner(props: ToolCardProps) { const isQuestionToolWithAnswers = isQuestionTool && permission?.answers && Object.keys(permission.answers).length > 0 + const hideResult = isQuestionToolWithAnswers || isExitPlanModeWithChoice return (
@@ -389,10 +398,10 @@ function ToolCardInner(props: ToolCardProps) { {FullToolView ? ( ) : ( - renderToolInput(props.block) + renderToolInput(props.block) )}
- {!isQuestionToolWithAnswers && ( + {!hideResult && (
{t('tool.result')}
@@ -432,32 +441,58 @@ function ToolCardInner(props: ToolCardProps) { ) ) : null} - {isAskUserQuestion && permission?.status === 'pending' ? ( - - ) : isRequestUserInput && permission?.status === 'pending' ? ( - - ) : ( - - )} + {(() => { + if (isAskUserQuestion && permission?.status === 'pending') { + return ( + + ) + } + + if (isExitPlanMode && permission?.status === 'pending') { + return ( + + ) + } + + if (isRequestUserInput && permission?.status === 'pending') { + return ( + + ) + } + + if (isExitPlanMode) { + return null + } + + return ( + + ) + })()} ) : null} diff --git a/web/src/components/ToolCard/exitPlanMode.ts b/web/src/components/ToolCard/exitPlanMode.ts new file mode 100644 index 000000000..758a2c469 --- /dev/null +++ b/web/src/components/ToolCard/exitPlanMode.ts @@ -0,0 +1,41 @@ +import type { ExitPlanImplementationMode } from '@/types/api' +import { isObject } from '@hapi/protocol' + +export function isExitPlanModeToolName(toolName: string): boolean { + return toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode' +} + +export function parseExitPlanModeInput(input: unknown): { plan: string | null } { + if (!isObject(input)) return { plan: null } + return { + plan: typeof input.plan === 'string' && input.plan.trim().length > 0 + ? input.plan + : null + } +} + +export function isExitPlanImplementationMode(value: unknown): value is ExitPlanImplementationMode { + return value === 'keep_context' || value === 'clear_context' +} + +export function getExitPlanImplementationModeLabel( + mode: ExitPlanImplementationMode, + t: (key: string) => string +): string { + return mode === 'keep_context' + ? t('tool.exitPlanMode.keepContext.title') + : t('tool.exitPlanMode.clearContext.title') +} + +export function getExitPlanImplementationModeDescription( + mode: ExitPlanImplementationMode, + t: (key: string) => string +): string { + return mode === 'keep_context' + ? t('tool.exitPlanMode.keepContext.description') + : t('tool.exitPlanMode.clearContext.description') +} + +export function getExitPlanImplementationModes(): ExitPlanImplementationMode[] { + return ['keep_context', 'clear_context'] +} diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 7289ec189..13d97a702 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -321,12 +321,12 @@ export const knownTools: Record , - title: () => 'Plan proposal', + title: () => 'Execute Plan', minimal: false }, exit_plan_mode: { icon: () => , - title: () => 'Plan proposal', + title: () => 'Execute Plan', minimal: false }, AskUserQuestion: { diff --git a/web/src/components/ToolCard/views/ExitPlanModeView.tsx b/web/src/components/ToolCard/views/ExitPlanModeView.tsx index 96f1aed1a..160d59f6b 100644 --- a/web/src/components/ToolCard/views/ExitPlanModeView.tsx +++ b/web/src/components/ToolCard/views/ExitPlanModeView.tsx @@ -1,11 +1,48 @@ import type { ToolViewProps } from '@/components/ToolCard/views/_all' -import { isObject } from '@hapi/protocol' +import { Badge } from '@/components/ui/badge' import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { parseExitPlanModeInput, isExitPlanImplementationMode, getExitPlanImplementationModeDescription, getExitPlanImplementationModeLabel } from '@/components/ToolCard/exitPlanMode' +import { useTranslation } from '@/lib/use-translation' export function ExitPlanModeView(props: ToolViewProps) { - const input = props.block.tool.input - if (!isObject(input)) return null - const plan = typeof input.plan === 'string' ? input.plan : null - if (!plan) return null - return + const { t } = useTranslation() + const { plan } = parseExitPlanModeInput(props.block.tool.input) + const permission = props.block.tool.permission + const implementationMode = isExitPlanImplementationMode(permission?.implementationMode) + ? permission.implementationMode + : null + + if (!plan && !implementationMode && !permission?.reason) return null + + return ( +
+ {plan ? ( +
+ +
+ ) : null} + + {implementationMode ? ( +
+
+ + {t('tool.exitPlanMode.selected')} + +
+
+ {getExitPlanImplementationModeLabel(implementationMode, t)} +
+
+ {getExitPlanImplementationModeDescription(implementationMode, t)} +
+
+ ) : null} + + {(permission?.status === 'denied' || permission?.status === 'canceled') && permission.reason ? ( +
+ {permission.reason} +
+ ) : null} +
+ ) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index c8eff2917..fe83f3b99 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -196,6 +196,16 @@ export default { 'tool.askUserQuestion.fallback': 'AskUserQuestion payload is not in the expected format. Type your answer:', 'tool.askUserQuestion.placeholder': 'Type your answer…', 'tool.askUserQuestion.otherPlaceholder': 'Or type your own answer…', + 'tool.exitPlanMode.title': 'Execute Plan', + 'tool.exitPlanMode.badge': 'Implementation', + 'tool.exitPlanMode.prompt': 'Choose how to start implementation after leaving plan mode.', + 'tool.exitPlanMode.start': 'Start implementation', + 'tool.exitPlanMode.selectOption': 'Please choose how to start implementation.', + 'tool.exitPlanMode.selected': 'Selected option', + 'tool.exitPlanMode.keepContext.title': 'Keep context', + 'tool.exitPlanMode.keepContext.description': 'Continue implementation in the current Claude transcript.', + 'tool.exitPlanMode.clearContext.title': 'Clear context', + 'tool.exitPlanMode.clearContext.description': 'Start implementation in a fresh Claude transcript.', 'tool.requestUserInput.textPlaceholder': 'Type your answer…', 'tool.requestUserInput.noteLabel': 'Additional note (optional)', 'tool.requestUserInput.notePlaceholder': 'Add a note…', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 32eaff2f1..f5b15cc3a 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -198,6 +198,16 @@ export default { 'tool.askUserQuestion.fallback': 'AskUserQuestion 格式不正确。请输入您的答案:', 'tool.askUserQuestion.placeholder': '输入您的答案…', 'tool.askUserQuestion.otherPlaceholder': '或输入您自己的答案…', + 'tool.exitPlanMode.title': '执行计划', + 'tool.exitPlanMode.badge': '实现方式', + 'tool.exitPlanMode.prompt': '选择退出计划模式后如何开始实现。', + 'tool.exitPlanMode.start': '开始实现', + 'tool.exitPlanMode.selectOption': '请选择如何开始实现。', + 'tool.exitPlanMode.selected': '已选选项', + 'tool.exitPlanMode.keepContext.title': '保留上下文', + 'tool.exitPlanMode.keepContext.description': '在当前 Claude transcript 中继续实现。', + 'tool.exitPlanMode.clearContext.title': '清除上下文', + 'tool.exitPlanMode.clearContext.description': '在全新的 Claude transcript 中开始实现。', 'tool.requestUserInput.textPlaceholder': '输入您的答案…', 'tool.requestUserInput.noteLabel': '补充说明(可选)', 'tool.requestUserInput.notePlaceholder': '添加备注…', diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index a9d0dd31a..3027b0441 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1 +1,7 @@ import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0a2b01b14..2500e3ff8 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -10,6 +10,7 @@ export type { AgentState, AttachmentMetadata, CodexCollaborationMode, + ExitPlanImplementationMode, PermissionMode, Session, SessionSummary, From 26b13e44ff11ccba962f61c6bad1463a40557154 Mon Sep 17 00:00:00 2001 From: Xiaoyi Date: Thu, 2 Apr 2026 00:47:47 -0700 Subject: [PATCH 2/8] fix(exit-plan): normalize metadata and tighten contracts --- .../claude/utils/permissionHandler.test.ts | 10 ++++ cli/src/claude/utils/permissionHandler.ts | 19 +++++++ .../permission/BasePermissionHandler.ts | 6 ++- hub/src/sync/rpcGateway.ts | 4 +- hub/src/sync/syncEngine.ts | 4 +- hub/src/web/routes/permissions.test.ts | 1 - shared/src/schemas.ts | 2 +- web/src/api/client.ts | 4 +- web/src/chat/normalizeAgent.test.ts | 50 +++++++++++++++++++ web/src/chat/normalizeAgent.ts | 23 +++++++-- web/src/chat/types.ts | 10 ++-- 11 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 web/src/chat/normalizeAgent.test.ts diff --git a/cli/src/claude/utils/permissionHandler.test.ts b/cli/src/claude/utils/permissionHandler.test.ts index 4bff0eed2..43baf19ba 100644 --- a/cli/src/claude/utils/permissionHandler.test.ts +++ b/cli/src/claude/utils/permissionHandler.test.ts @@ -195,6 +195,11 @@ describe('PermissionHandler exit_plan_mode', () => { effort: 'high', appendSystemPrompt: 'current append prompt' }); + expect(permissionHandler.getResponses().get('tool-exit-plan')).toMatchObject({ + approved: true, + mode: 'default', + implementationMode: 'keep_context' + }); expect(getAgentState().completedRequests).toMatchObject({ 'tool-exit-plan': { @@ -297,6 +302,11 @@ describe('PermissionHandler exit_plan_mode', () => { effort: 'high', appendSystemPrompt: 'current append prompt' }); + expect(permissionHandler.getResponses().get('tool-exit-plan-invalid-mode')).toMatchObject({ + approved: true, + mode: 'default', + implementationMode: 'keep_context' + }); expect(getAgentState().completedRequests).toMatchObject({ 'tool-exit-plan-invalid-mode': { status: 'approved', diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index d3fcf7ddc..df08a400d 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -155,6 +155,12 @@ function getExitPlanRestartPermissionMode(response: PermissionResponse): Permiss : 'default'; } +function normalizeClaudePermissionMode(mode: PermissionCompletion['mode']): PermissionMode | undefined { + return mode === 'default' || mode === 'acceptEdits' || mode === 'bypassPermissions' || mode === 'plan' + ? mode + : undefined; +} + function buildExitPlanRestartPrompt(input: unknown, implementationMode: ExitPlanImplementationMode): string { if (implementationMode === 'keep_context') { return PLAN_FAKE_RESTART; @@ -551,6 +557,19 @@ export class PermissionHandler extends BasePermissionHandler = { export type PermissionCompletion = { status: 'approved' | 'denied' | 'canceled'; reason?: string; - mode?: string; + mode?: PermissionMode; implementationMode?: ExitPlanImplementationMode; decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; allowTools?: string[]; @@ -118,6 +118,9 @@ export abstract class BasePermissionHandler | Record, implementationMode?: ExitPlanImplementationMode ): Promise { @@ -74,7 +74,7 @@ export class RpcGateway { async denyPermission( sessionId: string, requestId: string, - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + decision?: 'denied' | 'abort' ): Promise { await this.sessionRpc(sessionId, 'permission', { id: requestId, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index f52baeb73..88876c6f5 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -251,7 +251,7 @@ export class SyncEngine { requestId: string, mode?: PermissionMode, allowTools?: string[], - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort', + decision?: 'approved' | 'approved_for_session', answers?: Record | Record, implementationMode?: ExitPlanImplementationMode ): Promise { @@ -261,7 +261,7 @@ export class SyncEngine { async denyPermission( sessionId: string, requestId: string, - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + decision?: 'denied' | 'abort' ): Promise { await this.rpcGateway.denyPermission(sessionId, requestId, decision) } diff --git a/hub/src/web/routes/permissions.test.ts b/hub/src/web/routes/permissions.test.ts index 59b8116c3..12b32424a 100644 --- a/hub/src/web/routes/permissions.test.ts +++ b/hub/src/web/routes/permissions.test.ts @@ -34,7 +34,6 @@ function createSession(overrides?: Partial): Session { thinking: false, thinkingAt: 1, model: null, - effort: null, permissionMode: 'default' } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 7dee9bfe5..97647e1dc 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -67,7 +67,7 @@ export const AgentStateCompletedRequestSchema = z.object({ completedAt: z.number().nullish(), status: z.enum(['canceled', 'denied', 'approved']), reason: z.string().optional(), - mode: z.string().optional(), + mode: PermissionModeSchema.optional(), implementationMode: ExitPlanImplementationModeSchema.optional(), decision: z.enum(['approved', 'approved_for_session', 'denied', 'abort']).optional(), allowTools: z.array(z.string()).optional(), diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d6d6fe8b4..3cb32b75f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -342,7 +342,7 @@ export class ApiClient { mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' implementationMode?: ExitPlanImplementationMode allowTools?: string[] - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + decision?: 'approved' | 'approved_for_session' answers?: Record | Record } ): Promise { @@ -359,7 +359,7 @@ export class ApiClient { sessionId: string, requestId: string, options?: { - decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + decision?: 'denied' | 'abort' } ): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/deny`, { diff --git a/web/src/chat/normalizeAgent.test.ts b/web/src/chat/normalizeAgent.test.ts new file mode 100644 index 000000000..2dd15f1cd --- /dev/null +++ b/web/src/chat/normalizeAgent.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { normalizeAgentRecord } from '@/chat/normalizeAgent' + +describe('normalizeAgentRecord', () => { + it('drops invalid permission mode values from tool-result permissions', () => { + const normalized = normalizeAgentRecord( + 'message-1', + null, + 1, + { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'ok', + permissions: { + date: 1, + result: 'approved', + mode: 'not-a-real-mode', + implementationMode: 'not-a-real-implementation-mode' + } + }] + } + } + } + ) + + expect(normalized?.role).toBe('agent') + if (!normalized || normalized.role !== 'agent') { + throw new Error('Expected normalized agent message') + } + + expect(normalized.content[0]).toMatchObject({ + type: 'tool-result', + tool_use_id: 'tool-1' + }) + expect((normalized.content[0] as { permissions?: { mode?: unknown; implementationMode?: unknown } }).permissions).toEqual({ + date: 1, + result: 'approved', + decision: undefined, + allowedTools: undefined, + mode: undefined, + implementationMode: undefined + }) + }) +}) diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index a3d163b4a..4a725e7b7 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -1,7 +1,24 @@ import type { AgentEvent, NormalizedAgentContent, NormalizedMessage, ToolResultPermission } from '@/chat/types' -import { AGENT_MESSAGE_PAYLOAD_TYPE, asNumber, asString, isObject } from '@hapi/protocol' +import type { ExitPlanImplementationMode, PermissionMode } from '@/types/api' +import { AGENT_MESSAGE_PAYLOAD_TYPE, EXIT_PLAN_IMPLEMENTATION_MODES, PERMISSION_MODES, asNumber, asString, isObject } from '@hapi/protocol' import { isClaudeChatVisibleMessage } from '@hapi/protocol/messages' +function normalizePermissionMode(value: unknown): PermissionMode | undefined { + const mode = asString(value) + if (!mode) return undefined + return PERMISSION_MODES.includes(mode as PermissionMode) + ? mode as PermissionMode + : undefined +} + +function normalizeImplementationMode(value: unknown): ExitPlanImplementationMode | undefined { + const mode = asString(value) + if (!mode) return undefined + return EXIT_PLAN_IMPLEMENTATION_MODES.includes(mode as ExitPlanImplementationMode) + ? mode as ExitPlanImplementationMode + : undefined +} + function normalizeToolResultPermissions(value: unknown): ToolResultPermission | undefined { if (!isObject(value)) return undefined const date = asNumber(value.date) @@ -9,8 +26,8 @@ function normalizeToolResultPermissions(value: unknown): ToolResultPermission | if (date === null) return undefined if (result !== 'approved' && result !== 'denied') return undefined - const mode = asString(value.mode) ?? undefined - const implementationMode = asString(value.implementationMode) ?? undefined + const mode = normalizePermissionMode(value.mode) + const implementationMode = normalizeImplementationMode(value.implementationMode) const allowedTools = Array.isArray(value.allowedTools) ? value.allowedTools.filter((tool) => typeof tool === 'string') : undefined diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index d50cce532..d8300b61f 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -1,4 +1,4 @@ -import type { AttachmentMetadata, MessageStatus } from '@/types/api' +import type { AttachmentMetadata, ExitPlanImplementationMode, MessageStatus, PermissionMode } from '@/types/api' export type UsageData = { input_tokens: number @@ -24,8 +24,8 @@ export type AgentEvent = export type ToolResultPermission = { date: number result: 'approved' | 'denied' - mode?: string - implementationMode?: string + mode?: PermissionMode + implementationMode?: ExitPlanImplementationMode allowedTools?: string[] decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' } @@ -92,8 +92,8 @@ export type ToolPermission = { id: string status: 'pending' | 'approved' | 'denied' | 'canceled' reason?: string - mode?: string - implementationMode?: string + mode?: PermissionMode + implementationMode?: ExitPlanImplementationMode allowedTools?: string[] decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' answers?: Record | Record From 475f033de1ded518a33b48e1a6c612be1652be20 Mon Sep 17 00:00:00 2001 From: Xiaoyi Date: Thu, 2 Apr 2026 01:03:34 -0700 Subject: [PATCH 3/8] fix(web): polish exit-plan mode UI --- .../ToolCard/ExitPlanModeFooter.test.tsx | 3 +- .../ToolCard/ExitPlanModeFooter.tsx | 2 +- web/src/components/ToolCard/ToolCard.test.tsx | 145 ++++++++++++++++++ web/src/components/ToolCard/ToolCard.tsx | 44 ++++-- .../ToolCard/views/ExitPlanModeView.test.tsx | 85 ++++++++++ web/src/lib/locales/en.ts | 6 +- web/src/lib/locales/zh-CN.ts | 6 +- 7 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 web/src/components/ToolCard/ToolCard.test.tsx create mode 100644 web/src/components/ToolCard/views/ExitPlanModeView.test.tsx diff --git a/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx b/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx index d70f2501c..eb39faff8 100644 --- a/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx +++ b/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { I18nProvider } from '@/lib/i18n-context' import { ExitPlanModeFooter } from '@/components/ToolCard/ExitPlanModeFooter' @@ -118,5 +118,6 @@ describe('ExitPlanModeFooter', () => { fireEvent.click(screen.getByRole('button', { name: 'Deny' })) expect(api.denyPermission).toHaveBeenCalledWith('session-1', 'request-1') + await waitFor(() => expect(haptic.notification).toHaveBeenCalledWith('error')) }) }) diff --git a/web/src/components/ToolCard/ExitPlanModeFooter.tsx b/web/src/components/ToolCard/ExitPlanModeFooter.tsx index 226e2e9b1..06f433ce5 100644 --- a/web/src/components/ToolCard/ExitPlanModeFooter.tsx +++ b/web/src/components/ToolCard/ExitPlanModeFooter.tsx @@ -106,7 +106,7 @@ export function ExitPlanModeFooter(props: { const deny = async () => { if (loading || props.disabled) return setLoading('deny') - await run(() => props.api.denyPermission(props.sessionId, permission.id), 'success') + await run(() => props.api.denyPermission(props.sessionId, permission.id), 'error') setLoading(null) } diff --git a/web/src/components/ToolCard/ToolCard.test.tsx b/web/src/components/ToolCard/ToolCard.test.tsx new file mode 100644 index 000000000..f5d74bcee --- /dev/null +++ b/web/src/components/ToolCard/ToolCard.test.tsx @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import type { ToolCallBlock } from '@/chat/types' +import { I18nProvider } from '@/lib/i18n-context' +import { ToolCard } from '@/components/ToolCard/ToolCard' + +const haptic = { + selection: vi.fn(), + notification: vi.fn(), + impact: vi.fn() +} + +vi.mock('@/hooks/usePlatform', () => ({ + usePlatform: () => ({ + isTelegram: false, + isTouch: false, + haptic + }) +})) + +vi.mock('@/components/MarkdownRenderer', () => ({ + MarkdownRenderer: (props: { content: string }) =>
{props.content}
+})) + +function renderWithProviders(ui: React.ReactElement) { + return render( + + {ui} + + ) +} + +function createExitPlanBlock(overrides?: Partial): ToolCallBlock { + return { + kind: 'tool-call', + id: 'tool-1', + localId: null, + createdAt: 1, + tool: { + id: 'tool-1', + name: 'exit_plan_mode', + state: 'pending', + input: { plan: 'Implement feature X' }, + createdAt: 1, + startedAt: null, + completedAt: null, + description: null, + permission: { + id: 'permission-1', + status: 'pending' + }, + ...overrides + }, + children: [] + } +} + +function createTaskBlock(child: ToolCallBlock): ToolCallBlock { + return { + kind: 'tool-call', + id: 'task-1', + localId: null, + createdAt: 1, + tool: { + id: 'task-1', + name: 'Task', + state: 'running', + input: { + description: 'Main task' + }, + createdAt: 1, + startedAt: 1, + completedAt: null, + description: null + }, + children: [child] + } +} + +describe('ToolCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps exit-plan dialog focused on input and hides the result pane', () => { + const localStorageMock = { + getItem: vi.fn(() => 'en'), + setItem: vi.fn(), + removeItem: vi.fn() + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) + + renderWithProviders( + {}), + denyPermission: vi.fn(async () => {}) + } as never} + sessionId="session-1" + metadata={null} + disabled={false} + onDone={vi.fn()} + block={createExitPlanBlock()} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Execute Plan' })) + + expect(screen.getByText('Input')).toBeInTheDocument() + expect(screen.queryByText('Result')).not.toBeInTheDocument() + }) + + it('localizes exit-plan child labels inside task summaries', () => { + const localStorageMock = { + getItem: vi.fn(() => 'zh-CN'), + setItem: vi.fn(), + removeItem: vi.fn() + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) + + renderWithProviders( + {}), + denyPermission: vi.fn(async () => {}) + } as never} + sessionId="session-1" + metadata={null} + disabled={false} + onDone={vi.fn()} + block={createTaskBlock(createExitPlanBlock({ + state: 'completed', + permission: { + id: 'permission-1', + status: 'approved', + implementationMode: 'keep_context' + } + }))} + /> + ) + + expect(screen.getByText('执行计划')).toBeInTheDocument() + expect(screen.queryByText('Execute Plan')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index e8524c3ac..14c9cae8a 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -46,7 +46,23 @@ function ElapsedView(props: { from: number; active: boolean }) { ) } -function formatTaskChildLabel(child: ToolCallBlock, metadata: SessionMetadataSummary | null): string { +function getLocalizedToolTitle( + toolName: string, + fallbackTitle: string, + t: (key: string) => string +): string { + if (isExitPlanModeToolName(toolName)) { + return t('tool.exitPlanMode.title') + } + + return fallbackTitle +} + +function formatTaskChildLabel( + child: ToolCallBlock, + metadata: SessionMetadataSummary | null, + t: (key: string) => string +): string { const presentation = getToolPresentation({ toolName: child.tool.name, input: child.tool.input, @@ -55,12 +71,13 @@ function formatTaskChildLabel(child: ToolCallBlock, metadata: SessionMetadataSum description: child.tool.description, metadata }) + const title = getLocalizedToolTitle(child.tool.name, presentation.title, t) if (presentation.subtitle) { - return truncate(`${presentation.title}: ${presentation.subtitle}`, 140) + return truncate(`${title}: ${presentation.subtitle}`, 140) } - return presentation.title + return title } function TaskStateIcon(props: { state: ToolCallBlock['tool']['state'] }) { @@ -89,7 +106,11 @@ function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[ return { visible, remaining: children.length - visible.length } } -function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummary | null): ReactNode | null { +function renderTaskSummary( + block: ToolCallBlock, + metadata: SessionMetadataSummary | null, + t: (key: string) => string +): ReactNode | null { const summary = getTaskSummaryChildren(block) if (!summary) return null @@ -106,7 +127,7 @@ function renderTaskSummary(block: ToolCallBlock, metadata: SessionMetadataSummar - {formatTaskChildLabel(child, metadata)} + {formatTaskChildLabel(child, metadata, t)}
@@ -305,9 +326,9 @@ function ToolCardInner(props: ToolCardProps) { const toolName = props.block.tool.name const isExitPlanMode = isExitPlanModeToolName(toolName) - const toolTitle = isExitPlanMode ? t('tool.exitPlanMode.title') : presentation.title + const toolTitle = getLocalizedToolTitle(toolName, presentation.title, t) const subtitle = presentation.subtitle ?? props.block.tool.description - const taskSummary = renderTaskSummary(props.block, props.metadata) + const taskSummary = renderTaskSummary(props.block, props.metadata, t) const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt const showInline = !presentation.minimal && toolName !== 'Task' const CompactToolView = showInline ? getToolViewComponent(toolName) : null @@ -316,11 +337,6 @@ function ToolCardInner(props: ToolCardProps) { const permission = props.block.tool.permission const isAskUserQuestion = isAskUserQuestionToolName(toolName) const isRequestUserInput = isRequestUserInputToolName(toolName) - const isExitPlanModeWithChoice = isExitPlanMode - && (permission?.implementationMode !== undefined - || permission?.status === 'approved' - || permission?.status === 'denied' - || permission?.status === 'canceled') const isQuestionTool = isAskUserQuestion || isRequestUserInput const showsPermissionFooter = Boolean(permission && ( permission.status === 'pending' @@ -379,7 +395,7 @@ function ToolCardInner(props: ToolCardProps) { {header} - + {toolTitle} @@ -387,7 +403,7 @@ function ToolCardInner(props: ToolCardProps) { const isQuestionToolWithAnswers = isQuestionTool && permission?.answers && Object.keys(permission.answers).length > 0 - const hideResult = isQuestionToolWithAnswers || isExitPlanModeWithChoice + const hideResult = isQuestionToolWithAnswers || isExitPlanMode return (
diff --git a/web/src/components/ToolCard/views/ExitPlanModeView.test.tsx b/web/src/components/ToolCard/views/ExitPlanModeView.test.tsx new file mode 100644 index 000000000..adf85673d --- /dev/null +++ b/web/src/components/ToolCard/views/ExitPlanModeView.test.tsx @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { ToolCallBlock } from '@/chat/types' +import { I18nProvider } from '@/lib/i18n-context' +import { ExitPlanModeView } from '@/components/ToolCard/views/ExitPlanModeView' + +vi.mock('@/components/MarkdownRenderer', () => ({ + MarkdownRenderer: (props: { content: string }) =>
{props.content}
+})) + +function renderWithProviders(ui: React.ReactElement) { + return render( + + {ui} + + ) +} + +function createBlock(overrides?: Partial): ToolCallBlock { + return { + kind: 'tool-call', + id: 'tool-1', + localId: null, + createdAt: 1, + tool: { + id: 'tool-1', + name: 'exit_plan_mode', + state: 'completed', + input: { plan: 'Implement the approved plan' }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + ...overrides + }, + children: [] + } +} + +describe('ExitPlanModeView', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn(() => 'en'), + setItem: vi.fn(), + removeItem: vi.fn() + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) + }) + + it('shows the approved implementation choice', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Selected implementation')).toBeInTheDocument() + expect(screen.getByText('Clear context')).toBeInTheDocument() + expect(screen.getByText('Start implementation in a new Claude session.')).toBeInTheDocument() + }) + + it('shows denied reasons', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Need a smaller implementation scope first.')).toBeInTheDocument() + }) +}) diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index fe83f3b99..60d4009c6 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -201,11 +201,11 @@ export default { 'tool.exitPlanMode.prompt': 'Choose how to start implementation after leaving plan mode.', 'tool.exitPlanMode.start': 'Start implementation', 'tool.exitPlanMode.selectOption': 'Please choose how to start implementation.', - 'tool.exitPlanMode.selected': 'Selected option', + 'tool.exitPlanMode.selected': 'Selected implementation', 'tool.exitPlanMode.keepContext.title': 'Keep context', - 'tool.exitPlanMode.keepContext.description': 'Continue implementation in the current Claude transcript.', + 'tool.exitPlanMode.keepContext.description': 'Continue implementation in the current Claude session.', 'tool.exitPlanMode.clearContext.title': 'Clear context', - 'tool.exitPlanMode.clearContext.description': 'Start implementation in a fresh Claude transcript.', + 'tool.exitPlanMode.clearContext.description': 'Start implementation in a new Claude session.', 'tool.requestUserInput.textPlaceholder': 'Type your answer…', 'tool.requestUserInput.noteLabel': 'Additional note (optional)', 'tool.requestUserInput.notePlaceholder': 'Add a note…', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index f5b15cc3a..be8eb9aa1 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -203,11 +203,11 @@ export default { 'tool.exitPlanMode.prompt': '选择退出计划模式后如何开始实现。', 'tool.exitPlanMode.start': '开始实现', 'tool.exitPlanMode.selectOption': '请选择如何开始实现。', - 'tool.exitPlanMode.selected': '已选选项', + 'tool.exitPlanMode.selected': '已选实现方式', 'tool.exitPlanMode.keepContext.title': '保留上下文', - 'tool.exitPlanMode.keepContext.description': '在当前 Claude transcript 中继续实现。', + 'tool.exitPlanMode.keepContext.description': '在当前 Claude 会话中继续实现。', 'tool.exitPlanMode.clearContext.title': '清除上下文', - 'tool.exitPlanMode.clearContext.description': '在全新的 Claude transcript 中开始实现。', + 'tool.exitPlanMode.clearContext.description': '在新的 Claude 会话中开始实现。', 'tool.requestUserInput.textPlaceholder': '输入您的答案…', 'tool.requestUserInput.noteLabel': '补充说明(可选)', 'tool.requestUserInput.notePlaceholder': '添加备注…', From 5d1db0d98328cfb7c78111a6921f4067a8f2909e Mon Sep 17 00:00:00 2001 From: Xiaoyi Date: Thu, 2 Apr 2026 04:10:13 -0700 Subject: [PATCH 4/8] fix(exit-plan): harden permission metadata flow --- cli/src/claude/claudeRemoteLauncher.ts | 29 +-- .../claude/utils/permissionHandler.test.ts | 159 +++++++++++++ cli/src/claude/utils/permissionHandler.ts | 139 ++++++++--- .../utils/toolResultPermissions.test.ts | 38 +++ cli/src/claude/utils/toolResultPermissions.ts | 48 ++++ .../socket/handlers/cli/sessionHandlers.ts | 2 +- hub/src/store/normalizeAgentState.ts | 168 ++++++++++++++ hub/src/store/sessions.ts | 8 +- hub/src/sync/sessionCache.ts | 8 +- hub/src/sync/sessionModel.test.ts | 216 ++++++++++++++++++ hub/src/web/routes/permissions.test.ts | 1 + .../ToolCard/views/ExitPlanModeView.test.tsx | 16 ++ .../ToolCard/views/ExitPlanModeView.tsx | 12 +- web/src/lib/locales/en.ts | 2 + web/src/lib/locales/zh-CN.ts | 2 + 15 files changed, 773 insertions(+), 75 deletions(-) create mode 100644 cli/src/claude/utils/toolResultPermissions.test.ts create mode 100644 cli/src/claude/utils/toolResultPermissions.ts create mode 100644 hub/src/store/normalizeAgentState.ts diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index 513166ba7..2a0019d31 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -8,24 +8,16 @@ import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; import { formatClaudeMessageForInk } from "@/ui/messageFormatterInk"; import { logger } from "@/ui/logger"; import { SDKToLogConverter } from "./utils/sdkToLogConverter"; +import { buildClaudeToolResultPermissions } from "./utils/toolResultPermissions"; import { PLAN_FAKE_REJECT } from "./sdk/prompts"; import { EnhancedMode } from "./loop"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; -import type { ClaudePermissionMode, ExitPlanImplementationMode } from "@hapi/protocol/types"; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from "@/modules/common/remote/RemoteLauncherBase"; -interface PermissionsField { - date: number; - result: 'approved' | 'denied'; - mode?: ClaudePermissionMode; - implementationMode?: ExitPlanImplementationMode; - allowedTools?: string[]; -} - class ClaudeRemoteLauncher extends RemoteLauncherBase { private readonly session: Session; private abortController: AbortController | null = null; @@ -202,26 +194,9 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { const response = responses.get(c.tool_use_id); if (response) { - const permissions: PermissionsField = { - date: response.receivedAt || Date.now(), - result: response.approved ? 'approved' : 'denied' - }; - - if (response.mode) { - permissions.mode = response.mode; - } - - if (response.implementationMode) { - permissions.implementationMode = response.implementationMode; - } - - if (response.allowTools && response.allowTools.length > 0) { - permissions.allowedTools = response.allowTools; - } - content[i] = { ...c, - permissions + permissions: buildClaudeToolResultPermissions(response) }; } } diff --git a/cli/src/claude/utils/permissionHandler.test.ts b/cli/src/claude/utils/permissionHandler.test.ts index 43baf19ba..7ab429b91 100644 --- a/cli/src/claude/utils/permissionHandler.test.ts +++ b/cli/src/claude/utils/permissionHandler.test.ts @@ -316,3 +316,162 @@ describe('PermissionHandler exit_plan_mode', () => { }); }); }); + +describe('PermissionHandler metadata normalization', () => { + it('does not apply allowTools or mode side effects when question answers are missing', async () => { + const { session, rpcHandlers, getAgentState } = createSessionStub(); + const permissionHandler = new PermissionHandler(session as never); + + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-question-empty', + name: 'ask_user_question', + input: { + questions: [{ question: 'Proceed?' }] + } + }] + } + } as never); + + const questionCall = permissionHandler.handleToolCall( + 'ask_user_question', + { questions: [{ question: 'Proceed?' }] }, + { permissionMode: 'default' } as never, + { signal: new AbortController().signal } + ); + + const permissionRpc = rpcHandlers.get('permission'); + expect(permissionRpc).toBeTypeOf('function'); + + await permissionRpc?.({ + id: 'tool-question-empty', + approved: true, + mode: 'acceptEdits', + allowTools: ['Edit'], + answers: {} + }); + + await expect(questionCall).resolves.toEqual({ + behavior: 'deny', + message: 'No answers were provided.' + }); + + expect(session.setPermissionMode).not.toHaveBeenCalled(); + expect(permissionHandler.getResponses().get('tool-question-empty')).toMatchObject({ + approved: false, + reason: 'No answers were provided.' + }); + expect(permissionHandler.getResponses().get('tool-question-empty')?.mode).toBeUndefined(); + expect(permissionHandler.getResponses().get('tool-question-empty')?.allowTools).toBeUndefined(); + expect(getAgentState().completedRequests).toMatchObject({ + 'tool-question-empty': { + status: 'denied', + reason: 'No answers were provided.' + } + }); + + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-edit-after-empty-answer', + name: 'Edit', + input: { + file_path: 'src/example.ts', + old_string: 'before', + new_string: 'after' + } + }] + } + } as never); + + const abortController = new AbortController(); + const editCall = permissionHandler.handleToolCall( + 'Edit', + { + file_path: 'src/example.ts', + old_string: 'before', + new_string: 'after' + }, + { permissionMode: 'default' } as never, + { signal: abortController.signal } + ); + + expect(getAgentState().requests).toMatchObject({ + 'tool-edit-after-empty-answer': { + tool: 'Edit' + } + }); + + abortController.abort(); + await expect(editCall).rejects.toThrow('Permission request aborted'); + }); + + it('preserves permission decisions in responses and completed requests', async () => { + const { session, rpcHandlers, getAgentState } = createSessionStub(); + const permissionHandler = new PermissionHandler(session as never); + + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-edit-decision', + name: 'Edit', + input: { + file_path: 'src/example.ts', + old_string: 'before', + new_string: 'after' + } + }] + } + } as never); + + const toolCall = permissionHandler.handleToolCall( + 'Edit', + { + file_path: 'src/example.ts', + old_string: 'before', + new_string: 'after' + }, + { permissionMode: 'default' } as never, + { signal: new AbortController().signal } + ); + + const permissionRpc = rpcHandlers.get('permission'); + expect(permissionRpc).toBeTypeOf('function'); + + await permissionRpc?.({ + id: 'tool-edit-decision', + approved: true, + decision: 'approved_for_session' + }); + + await expect(toolCall).resolves.toEqual({ + behavior: 'allow', + updatedInput: { + file_path: 'src/example.ts', + old_string: 'before', + new_string: 'after' + } + }); + + expect(permissionHandler.getResponses().get('tool-edit-decision')).toMatchObject({ + approved: true, + decision: 'approved_for_session' + }); + expect(getAgentState().completedRequests).toMatchObject({ + 'tool-edit-decision': { + status: 'approved', + decision: 'approved_for_session' + } + }); + }); +}); diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index df08a400d..95c40c091 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -26,6 +26,7 @@ import { interface PermissionResponse { id: string; approved: boolean; + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; reason?: string; mode?: PermissionMode; implementationMode?: ExitPlanImplementationMode; @@ -161,6 +162,21 @@ function normalizeClaudePermissionMode(mode: PermissionCompletion['mode']): Perm : undefined; } +function normalizeClaudePermissionDecision( + status: PermissionCompletion['status'], + decision: PermissionResponse['decision'] +): PermissionCompletion['decision'] | undefined { + if (status === 'approved') { + return decision === 'approved' || decision === 'approved_for_session' + ? decision + : undefined; + } + + return decision === 'denied' || decision === 'abort' + ? decision + : undefined; +} + function buildExitPlanRestartPrompt(input: unknown, implementationMode: ExitPlanImplementationMode): string { if (implementationMode === 'keep_context') { return PLAN_FAKE_RESTART; @@ -211,6 +227,42 @@ export class PermissionHandler extends BasePermissionHandler 0) { + response.allowTools.forEach(tool => { + if (isQuestionToolName(tool)) { + return; + } + if (tool.startsWith('Bash(') || tool === 'Bash') { + this.parseBashPermission(tool); + } else { + this.allowedTools.add(tool); + } + }); + } + + const normalizedMode = normalizeClaudePermissionMode(response.mode); + if (normalizedMode) { + this.permissionMode = normalizedMode; + this.session.setPermissionMode(normalizedMode); + } + } + /** * Handler response */ @@ -224,38 +276,37 @@ export class PermissionHandler extends BasePermissionHandler 0) { - response.allowTools.forEach(tool => { - if (isQuestionToolName(tool)) { - return; - } - if (tool.startsWith('Bash(') || tool === 'Bash') { - this.parseBashPermission(tool); - } else { - this.allowedTools.add(tool); - } - }); - } - - // Update permission mode - if (response.mode && !isExitPlanModeRequest) { - this.permissionMode = response.mode; - this.session.setPermissionMode(response.mode); - } - // Handle ask_user_question if (isAskUserQuestionToolName(pending.toolName)) { const answers = response.answers ?? {}; - if (Object.keys(answers).length === 0) { - pending.resolve({ behavior: 'deny', message: 'No answers were provided.' }); + if (!response.approved) { + completion.status = 'denied'; + completion.reason = completion.reason ?? response.reason ?? 'The user denied the request.'; + completion.mode = undefined; + completion.allowTools = undefined; + completion.decision = normalizeClaudePermissionDecision(completion.status, response.decision); + this.syncResponseSnapshot(response, completion); + pending.resolve({ behavior: 'deny', message: response.reason || 'The user denied the request.' }); + } else if (Object.keys(answers).length === 0) { completion.status = 'denied'; completion.reason = completion.reason ?? 'No answers were provided.'; + completion.mode = undefined; + completion.allowTools = undefined; + completion.decision = normalizeClaudePermissionDecision(completion.status, response.decision); + this.syncResponseSnapshot(response, completion); + pending.resolve({ behavior: 'deny', message: 'No answers were provided.' }); } else { + this.applyPermissionSideEffects(response); + completion.decision = normalizeClaudePermissionDecision(completion.status, response.decision); + this.syncResponseSnapshot(response, completion); pending.resolve({ behavior: 'allow', updatedInput: buildAskUserQuestionUpdatedInput(pending.input, answers) @@ -267,11 +318,26 @@ export class PermissionHandler extends BasePermissionHandler) || {} } : { behavior: 'deny', message: response.reason || `The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.` }; @@ -554,20 +631,10 @@ export class PermissionHandler extends BasePermissionHandler { + it('includes approval decision and normalized metadata', () => { + expect(buildClaudeToolResultPermissions({ + approved: true, + receivedAt: 123, + mode: 'acceptEdits', + implementationMode: 'clear_context', + allowTools: ['Edit'], + decision: 'approved_for_session' + })).toEqual({ + date: 123, + result: 'approved', + mode: 'acceptEdits', + implementationMode: 'clear_context', + allowedTools: ['Edit'], + decision: 'approved_for_session' + }) + }) + + it('falls back to current time when receivedAt is absent', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-04-02T12:00:00Z')) + + expect(buildClaudeToolResultPermissions({ + approved: false, + decision: 'abort' + })).toEqual({ + date: Date.now(), + result: 'denied', + decision: 'abort' + }) + + vi.useRealTimers() + }) +}) diff --git a/cli/src/claude/utils/toolResultPermissions.ts b/cli/src/claude/utils/toolResultPermissions.ts new file mode 100644 index 000000000..cfc515a52 --- /dev/null +++ b/cli/src/claude/utils/toolResultPermissions.ts @@ -0,0 +1,48 @@ +import type { ClaudePermissionMode, ExitPlanImplementationMode } from "@hapi/protocol/types"; + +export type ClaudeToolResultPermissionDecision = 'approved' | 'approved_for_session' | 'denied' | 'abort'; + +export type ClaudeToolResultPermissionResponse = { + approved: boolean; + receivedAt?: number; + mode?: ClaudePermissionMode; + implementationMode?: ExitPlanImplementationMode; + allowTools?: string[]; + decision?: ClaudeToolResultPermissionDecision; +}; + +export type ClaudeToolResultPermissions = { + date: number; + result: 'approved' | 'denied'; + mode?: ClaudePermissionMode; + implementationMode?: ExitPlanImplementationMode; + allowedTools?: string[]; + decision?: ClaudeToolResultPermissionDecision; +}; + +export function buildClaudeToolResultPermissions( + response: ClaudeToolResultPermissionResponse +): ClaudeToolResultPermissions { + const permissions: ClaudeToolResultPermissions = { + date: response.receivedAt || Date.now(), + result: response.approved ? 'approved' : 'denied' + }; + + if (response.mode) { + permissions.mode = response.mode; + } + + if (response.implementationMode) { + permissions.implementationMode = response.implementationMode; + } + + if (response.allowTools && response.allowTools.length > 0) { + permissions.allowedTools = response.allowTools; + } + + if (response.decision) { + permissions.decision = response.decision; + } + + return permissions; +} diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 67ec014b7..29f862c96 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -224,7 +224,7 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session t: 'update-session' as const, sid, metadata: null, - agentState: { version: result.version, value: agentState } + agentState: { version: result.version, value: result.value } } } socket.to(`session:${sid}`).emit('update', update) diff --git a/hub/src/store/normalizeAgentState.ts b/hub/src/store/normalizeAgentState.ts new file mode 100644 index 000000000..0fdd10bd2 --- /dev/null +++ b/hub/src/store/normalizeAgentState.ts @@ -0,0 +1,168 @@ +import { isObject } from '@hapi/protocol' +import { + AgentStateCompletedRequestSchema, + AgentStateRequestSchema, + AgentStateSchema, + ExitPlanImplementationModeSchema, + PermissionModeSchema +} from '@hapi/protocol/schemas' +import type { AgentState } from '@hapi/protocol/types' +import { z } from 'zod' + +const PermissionDecisionSchema = z.enum(['approved', 'approved_for_session', 'denied', 'abort']) +const PermissionAnswersSchema = z.union([ + z.record(z.string(), z.array(z.string())), + z.record(z.string(), z.object({ answers: z.array(z.string()) })) +]) + +function normalizeNullishNumber(value: unknown): number | null | undefined { + if (typeof value === 'number') return value + if (value === null) return null + return undefined +} + +function normalizeNullishBoolean(value: unknown): boolean | null | undefined { + if (typeof value === 'boolean') return value + if (value === null) return null + return undefined +} + +function normalizeMode(value: unknown): z.infer | undefined { + const parsed = PermissionModeSchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} + +function normalizeImplementationMode( + value: unknown +): z.infer | undefined { + const parsed = ExitPlanImplementationModeSchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} + +function normalizeDecision(value: unknown): z.infer | undefined { + const parsed = PermissionDecisionSchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} + +function normalizeAnswers(value: unknown): z.infer | undefined { + const parsed = PermissionAnswersSchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} + +function normalizeAllowTools(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + const tools = value.filter((entry): entry is string => typeof entry === 'string') + return tools.length > 0 ? tools : undefined +} + +function normalizeRequest(value: unknown) { + if (!isObject(value)) return null + if (typeof value.tool !== 'string' || !('arguments' in value)) return null + + const candidate: Record = { + tool: value.tool, + arguments: value.arguments + } + + const createdAt = normalizeNullishNumber(value.createdAt) + if (createdAt !== undefined) { + candidate.createdAt = createdAt + } + + const parsed = AgentStateRequestSchema.safeParse(candidate) + return parsed.success ? parsed.data : null +} + +function normalizeCompletedRequest(value: unknown) { + if (!isObject(value)) return null + if (typeof value.tool !== 'string' || !('arguments' in value)) return null + if (value.status !== 'approved' && value.status !== 'denied' && value.status !== 'canceled') return null + + const candidate: Record = { + tool: value.tool, + arguments: value.arguments, + status: value.status + } + + const createdAt = normalizeNullishNumber(value.createdAt) + if (createdAt !== undefined) { + candidate.createdAt = createdAt + } + + const completedAt = normalizeNullishNumber(value.completedAt) + if (completedAt !== undefined) { + candidate.completedAt = completedAt + } + + if (typeof value.reason === 'string') { + candidate.reason = value.reason + } + + const mode = normalizeMode(value.mode) + if (mode !== undefined) { + candidate.mode = mode + } + + const implementationMode = normalizeImplementationMode(value.implementationMode) + if (implementationMode !== undefined) { + candidate.implementationMode = implementationMode + } + + const decision = normalizeDecision(value.decision) + if (decision !== undefined) { + candidate.decision = decision + } + + const allowTools = normalizeAllowTools(value.allowTools) + if (allowTools !== undefined) { + candidate.allowTools = allowTools + } + + const answers = normalizeAnswers(value.answers) + if (answers !== undefined) { + candidate.answers = answers + } + + const parsed = AgentStateCompletedRequestSchema.safeParse(candidate) + return parsed.success ? parsed.data : null +} + +export function normalizeAgentState(value: unknown): AgentState | null { + if (value === null || value === undefined) return null + + const parsed = AgentStateSchema.safeParse(value) + if (parsed.success) { + return parsed.data + } + + if (!isObject(value)) { + return null + } + + const agentState: AgentState = {} + + const controlledByUser = normalizeNullishBoolean(value.controlledByUser) + if (controlledByUser !== undefined) { + agentState.controlledByUser = controlledByUser + } + + if (isObject(value.requests)) { + const requests = Object.fromEntries( + Object.entries(value.requests) + .map(([id, request]) => [id, normalizeRequest(request)] as const) + .filter((entry): entry is [string, NonNullable>] => entry[1] !== null) + ) + agentState.requests = requests + } + + if (isObject(value.completedRequests)) { + const completedRequests = Object.fromEntries( + Object.entries(value.completedRequests) + .map(([id, request]) => [id, normalizeCompletedRequest(request)] as const) + .filter((entry): entry is [string, NonNullable>] => entry[1] !== null) + ) + agentState.completedRequests = completedRequests + } + + return agentState +} diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 4e30f6d64..c255e0c46 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -1,6 +1,7 @@ import type { Database } from 'bun:sqlite' import { randomUUID } from 'node:crypto' +import { normalizeAgentState } from './normalizeAgentState' import type { StoredSession, VersionedUpdateResult } from './types' import { safeJsonParse } from './json' import { updateVersionedField } from './versionedUpdates' @@ -72,7 +73,8 @@ export function getOrCreateSession( const id = randomUUID() const metadataJson = JSON.stringify(metadata) - const agentStateJson = agentState === null || agentState === undefined ? null : JSON.stringify(agentState) + const normalizedAgentState = normalizeAgentState(agentState) + const agentStateJson = normalizedAgentState === null ? null : JSON.stringify(normalizedAgentState) db.prepare(` INSERT INTO sessions ( @@ -155,7 +157,7 @@ export function updateSessionAgentState( namespace: string ): VersionedUpdateResult { const now = Date.now() - const normalized = agentState ?? null + const normalized = normalizeAgentState(agentState) return updateVersionedField({ db, @@ -167,7 +169,7 @@ export function updateSessionAgentState( expectedVersion, value: normalized, encode: (value) => (value === null ? null : JSON.stringify(value)), - decode: safeJsonParse, + decode: (value) => normalizeAgentState(safeJsonParse(value)), setClauses: ['updated_at = @updated_at', 'seq = seq + 1'], params: { updated_at: now } }) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 618447c7d..aabb51b06 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1,6 +1,7 @@ -import { AgentStateSchema, MetadataSchema, TeamStateSchema } from '@hapi/protocol/schemas' +import { MetadataSchema, TeamStateSchema } from '@hapi/protocol/schemas' import type { CodexCollaborationMode, PermissionMode, Session } from '@hapi/protocol/types' import type { Store } from '../store' +import { normalizeAgentState } from '../store/normalizeAgentState' import { clampAliveTime } from './aliveTime' import { EventPublisher } from './eventPublisher' import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos' @@ -100,10 +101,7 @@ export class SessionCache { return parsed.success ? parsed.data : null })() - const agentState = (() => { - const parsed = AgentStateSchema.safeParse(stored.agentState) - return parsed.success ? parsed.data : null - })() + const agentState = normalizeAgentState(stored.agentState) const todos = (() => { if (stored.todos === null) return undefined diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 4d678136c..c2a5927fa 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -313,4 +313,220 @@ describe('session model', () => { engine.stop() } }) + + it('sanitizes invalid completed request modes before storing new agent state', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-invalid-agent-state-create', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + { + requests: {}, + completedRequests: { + 'request-1': { + tool: 'Edit', + arguments: { file_path: 'src/example.ts' }, + createdAt: 1, + completedAt: 2, + status: 'approved', + mode: 'legacy_mode', + implementationMode: 'clear_context' + } + } + }, + 'default' + ) + + expect(session.agentState?.completedRequests?.['request-1']).toEqual({ + tool: 'Edit', + arguments: { file_path: 'src/example.ts' }, + createdAt: 1, + completedAt: 2, + status: 'approved', + implementationMode: 'clear_context' + }) + expect(store.sessions.getSession(session.id)?.agentState).toEqual({ + requests: {}, + completedRequests: { + 'request-1': { + tool: 'Edit', + arguments: { file_path: 'src/example.ts' }, + createdAt: 1, + completedAt: 2, + status: 'approved', + implementationMode: 'clear_context' + } + } + }) + }) + + it('sanitizes invalid completed request modes on agent state updates', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-invalid-agent-state-update', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + null, + 'default' + ) + + const result = store.sessions.updateSessionAgentState( + session.id, + { + requests: {}, + completedRequests: { + 'request-1': { + tool: 'Edit', + arguments: { file_path: 'src/example.ts' }, + createdAt: 1, + completedAt: 2, + status: 'approved', + mode: 'legacy_mode', + implementationMode: 'clear_context' + } + } + }, + session.agentStateVersion, + 'default' + ) + + expect(result).toEqual({ + result: 'success', + version: session.agentStateVersion + 1, + value: { + requests: {}, + completedRequests: { + 'request-1': { + tool: 'Edit', + arguments: { file_path: 'src/example.ts' }, + createdAt: 1, + completedAt: 2, + status: 'approved', + implementationMode: 'clear_context' + } + } + } + }) + if (result.result !== 'success') { + throw new Error('Expected success result') + } + + const refreshed = cache.refreshSession(session.id) + expect(refreshed?.agentState).toEqual(result.value as never) + }) + + it('preserves sanitized agent state when reloading legacy rows with invalid completed request modes', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-invalid-agent-state-reload', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + null, + 'default' + ) + + const db = (store.sessions as unknown as { db: import('bun:sqlite').Database }).db + db.prepare('UPDATE sessions SET agent_state = @agent_state WHERE id = @id').run({ + id: session.id, + agent_state: JSON.stringify({ + requests: {}, + completedRequests: { + 'request-1': { + tool: 'Edit', + arguments: { file_path: 'src/example.ts' }, + createdAt: 1, + completedAt: 2, + status: 'approved', + mode: 'legacy_mode', + implementationMode: 'keep_context' + } + } + }) + }) + + const refreshed = cache.refreshSession(session.id) + + expect(refreshed?.agentState).toEqual({ + requests: {}, + completedRequests: { + 'request-1': { + tool: 'Edit', + arguments: { file_path: 'src/example.ts' }, + createdAt: 1, + completedAt: 2, + status: 'approved', + implementationMode: 'keep_context' + } + } + }) + }) + + it('returns sanitized agent state on update version mismatches', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-invalid-agent-state-mismatch', + { path: '/tmp/project', host: 'localhost', flavor: 'claude' }, + null, + 'default' + ) + + const db = (store.sessions as unknown as { db: import('bun:sqlite').Database }).db + db.prepare(` + UPDATE sessions + SET agent_state = @agent_state, + agent_state_version = agent_state_version + 1 + WHERE id = @id + `).run({ + id: session.id, + agent_state: JSON.stringify({ + requests: {}, + completedRequests: { + 'request-1': { + tool: 'Edit', + arguments: { file_path: 'src/example.ts' }, + createdAt: 1, + completedAt: 2, + status: 'approved', + mode: 'legacy_mode', + decision: 'legacy_decision', + implementationMode: 'keep_context' + } + } + }) + }) + + const result = store.sessions.updateSessionAgentState( + session.id, + { requests: {}, completedRequests: {} }, + session.agentStateVersion, + 'default' + ) + + expect(result).toEqual({ + result: 'version-mismatch', + version: session.agentStateVersion + 1, + value: { + requests: {}, + completedRequests: { + 'request-1': { + tool: 'Edit', + arguments: { file_path: 'src/example.ts' }, + createdAt: 1, + completedAt: 2, + status: 'approved', + implementationMode: 'keep_context' + } + } + } + }) + }) }) diff --git a/hub/src/web/routes/permissions.test.ts b/hub/src/web/routes/permissions.test.ts index 12b32424a..59b8116c3 100644 --- a/hub/src/web/routes/permissions.test.ts +++ b/hub/src/web/routes/permissions.test.ts @@ -34,6 +34,7 @@ function createSession(overrides?: Partial): Session { thinking: false, thinkingAt: 1, model: null, + effort: null, permissionMode: 'default' } diff --git a/web/src/components/ToolCard/views/ExitPlanModeView.test.tsx b/web/src/components/ToolCard/views/ExitPlanModeView.test.tsx index adf85673d..d275d92bb 100644 --- a/web/src/components/ToolCard/views/ExitPlanModeView.test.tsx +++ b/web/src/components/ToolCard/views/ExitPlanModeView.test.tsx @@ -82,4 +82,20 @@ describe('ExitPlanModeView', () => { expect(screen.getByText('Need a smaller implementation scope first.')).toBeInTheDocument() }) + + it('shows fallback denied copy when no explicit reason is present', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Plan not approved.')).toBeInTheDocument() + }) }) diff --git a/web/src/components/ToolCard/views/ExitPlanModeView.tsx b/web/src/components/ToolCard/views/ExitPlanModeView.tsx index 160d59f6b..58bf9284d 100644 --- a/web/src/components/ToolCard/views/ExitPlanModeView.tsx +++ b/web/src/components/ToolCard/views/ExitPlanModeView.tsx @@ -11,8 +11,14 @@ export function ExitPlanModeView(props: ToolViewProps) { const implementationMode = isExitPlanImplementationMode(permission?.implementationMode) ? permission.implementationMode : null + const fallbackReason = permission?.status === 'denied' + ? t('tool.exitPlanMode.denied') + : permission?.status === 'canceled' + ? t('tool.exitPlanMode.canceled') + : null + const denialReason = permission?.reason ?? fallbackReason - if (!plan && !implementationMode && !permission?.reason) return null + if (!plan && !implementationMode && !denialReason) return null return (
@@ -38,9 +44,9 @@ export function ExitPlanModeView(props: ToolViewProps) {
) : null} - {(permission?.status === 'denied' || permission?.status === 'canceled') && permission.reason ? ( + {(permission?.status === 'denied' || permission?.status === 'canceled') && denialReason ? (
- {permission.reason} + {denialReason}
) : null}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 60d4009c6..1accc5b99 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -202,6 +202,8 @@ export default { 'tool.exitPlanMode.start': 'Start implementation', 'tool.exitPlanMode.selectOption': 'Please choose how to start implementation.', 'tool.exitPlanMode.selected': 'Selected implementation', + 'tool.exitPlanMode.denied': 'Plan not approved.', + 'tool.exitPlanMode.canceled': 'Plan execution canceled.', 'tool.exitPlanMode.keepContext.title': 'Keep context', 'tool.exitPlanMode.keepContext.description': 'Continue implementation in the current Claude session.', 'tool.exitPlanMode.clearContext.title': 'Clear context', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index be8eb9aa1..6c3587585 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -204,6 +204,8 @@ export default { 'tool.exitPlanMode.start': '开始实现', 'tool.exitPlanMode.selectOption': '请选择如何开始实现。', 'tool.exitPlanMode.selected': '已选实现方式', + 'tool.exitPlanMode.denied': '计划未获批准。', + 'tool.exitPlanMode.canceled': '计划执行已取消。', 'tool.exitPlanMode.keepContext.title': '保留上下文', 'tool.exitPlanMode.keepContext.description': '在当前 Claude 会话中继续实现。', 'tool.exitPlanMode.clearContext.title': '清除上下文', From 3674cf8d20bcf0d8a34913fb0d762ac159bdd2d5 Mon Sep 17 00:00:00 2001 From: Xiaoyi Date: Wed, 8 Apr 2026 01:54:15 -0700 Subject: [PATCH 5/8] fix(exit-plan): clear session-scoped allowlists on clear_context In clear_context mode, only the persisted session ID was cleared but the in-memory allowedTools, allowedBashLiterals, and allowedBashPrefixes sets were preserved. This let stale "allow for this session" approvals carry over into the fresh-context restart. Clear all three sets alongside clearSessionId() so the new session starts with a clean permission slate. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../claude/utils/permissionHandler.test.ts | 112 ++++++++++++++++++ cli/src/claude/utils/permissionHandler.ts | 5 +- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/cli/src/claude/utils/permissionHandler.test.ts b/cli/src/claude/utils/permissionHandler.test.ts index 7ab429b91..6055cdbbd 100644 --- a/cli/src/claude/utils/permissionHandler.test.ts +++ b/cli/src/claude/utils/permissionHandler.test.ts @@ -257,6 +257,118 @@ describe('PermissionHandler exit_plan_mode', () => { }); }); + it('clears session-scoped tool allowlists on clear_context so stale approvals do not carry over', async () => { + const { session, rpcHandlers } = createSessionStub(); + const permissionHandler = new PermissionHandler(session as never); + + // Step 1: Approve a Write tool "for session" so it's added to allowedTools + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-write-1', + name: 'Write', + input: { file_path: 'foo.ts', content: 'hi' } + }] + } + } as never); + + const writeCall = permissionHandler.handleToolCall( + 'Write', + { file_path: 'foo.ts', content: 'hi' }, + { permissionMode: 'default' } as never, + { signal: new AbortController().signal } + ); + + const rpc1 = rpcHandlers.get('permission')!; + await rpc1({ + id: 'tool-write-1', + approved: true, + decision: 'approved_for_session', + allowTools: ['Write'] + }); + await writeCall; + + // Step 2: Same tool is now auto-allowed (no permission needed) + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-write-2', + name: 'Write', + input: { file_path: 'bar.ts', content: 'hi' } + }] + } + } as never); + + const autoResult = await permissionHandler.handleToolCall( + 'Write', + { file_path: 'bar.ts', content: 'hi' }, + { permissionMode: 'default' } as never, + { signal: new AbortController().signal } + ); + expect(autoResult.behavior).toBe('allow'); + + // Step 3: Trigger clear_context exit plan + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-exit-clear', + name: 'exit_plan_mode', + input: { plan: 'Fresh start' } + }] + } + } as never); + + const exitCall = permissionHandler.handleToolCall( + 'exit_plan_mode', + { plan: 'Fresh start' }, + { permissionMode: 'plan' } as never, + { signal: new AbortController().signal } + ); + + const rpc2 = rpcHandlers.get('permission')!; + await rpc2({ + id: 'tool-exit-clear', + approved: true, + mode: 'default', + implementationMode: 'clear_context' + }); + await exitCall; + + // Step 4: Write should now require permission again (no longer auto-allowed) + const abortController = new AbortController(); + permissionHandler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'tool-write-3', + name: 'Write', + input: { file_path: 'baz.ts', content: 'hi' } + }] + } + } as never); + + const postClearCall = permissionHandler.handleToolCall( + 'Write', + { file_path: 'baz.ts', content: 'hi' }, + { permissionMode: 'default' } as never, + { signal: abortController.signal } + ); + + abortController.abort(); + await expect(postClearCall).rejects.toThrow('Permission request aborted'); + }); + it('normalizes invalid post-plan modes to default before updating session state', async () => { const { session, rpcHandlers, getAgentState } = createSessionStub(); const permissionHandler = new PermissionHandler(session as never); diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index 95c40c091..f6e784e40 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -364,7 +364,10 @@ export class PermissionHandler extends BasePermissionHandler Date: Wed, 8 Apr 2026 02:22:53 -0700 Subject: [PATCH 6/8] fix(web): send post-plan permission mode from ExitPlanModeFooter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer only sent implementationMode, so every exit-plan approval from the web restarted in default permission mode regardless of user intent. Derive mode from the implementation choice: - keep_context → default (conservative, approve tools individually) - clear_context → acceptEdits (trust the plan, auto-approve edits) via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../ToolCard/ExitPlanModeFooter.test.tsx | 28 ++++++++++++++++++- .../ToolCard/ExitPlanModeFooter.tsx | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx b/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx index eb39faff8..1ee2ef5d8 100644 --- a/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx +++ b/web/src/components/ToolCard/ExitPlanModeFooter.test.tsx @@ -53,7 +53,7 @@ describe('ExitPlanModeFooter', () => { Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true }) }) - it('submits the selected implementationMode', async () => { + it('submits the selected implementationMode with correct permission mode', async () => { const api = { approvePermission: vi.fn(async () => {}), denyPermission: vi.fn(async () => {}) @@ -73,10 +73,36 @@ describe('ExitPlanModeFooter', () => { fireEvent.click(screen.getByRole('button', { name: 'Start implementation' })) expect(api.approvePermission).toHaveBeenCalledWith('session-1', 'request-1', { + mode: 'acceptEdits', implementationMode: 'clear_context' }) }) + it('sends default permission mode for keep_context', async () => { + const api = { + approvePermission: vi.fn(async () => {}), + denyPermission: vi.fn(async () => {}) + } + + renderWithProviders( + + ) + + fireEvent.click(screen.getByText('Keep context')) + fireEvent.click(screen.getByRole('button', { name: 'Start implementation' })) + + expect(api.approvePermission).toHaveBeenCalledWith('session-1', 'request-1', { + mode: 'default', + implementationMode: 'keep_context' + }) + }) + it('shows a validation error before submission when no option is selected', async () => { const api = { approvePermission: vi.fn(async () => {}), diff --git a/web/src/components/ToolCard/ExitPlanModeFooter.tsx b/web/src/components/ToolCard/ExitPlanModeFooter.tsx index 06f433ce5..cc871d24e 100644 --- a/web/src/components/ToolCard/ExitPlanModeFooter.tsx +++ b/web/src/components/ToolCard/ExitPlanModeFooter.tsx @@ -98,6 +98,7 @@ export function ExitPlanModeFooter(props: { setLoading('approve') await run(() => props.api.approvePermission(props.sessionId, permission.id, { + mode: selectedMode === 'clear_context' ? 'acceptEdits' : 'default', implementationMode: selectedMode }), 'success') setLoading(null) From 4711952470d141e0389ed4d5e09a89423e28c0fa Mon Sep 17 00:00:00 2001 From: Xiaoyi Date: Wed, 8 Apr 2026 02:28:30 -0700 Subject: [PATCH 7/8] feat(web): add permission mode selector to exit-plan-mode UI Let users choose the post-plan permission mode (Normal / Accept edits / YOLO) alongside the implementation mode, instead of hardcoding it. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --- .../ToolCard/ExitPlanModeFooter.tsx | 30 ++++++++++++++++++- web/src/components/ToolCard/exitPlanMode.ts | 28 +++++++++++++++++ web/src/lib/locales/en.ts | 7 +++++ web/src/lib/locales/zh-CN.ts | 7 +++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/web/src/components/ToolCard/ExitPlanModeFooter.tsx b/web/src/components/ToolCard/ExitPlanModeFooter.tsx index cc871d24e..52f232add 100644 --- a/web/src/components/ToolCard/ExitPlanModeFooter.tsx +++ b/web/src/components/ToolCard/ExitPlanModeFooter.tsx @@ -12,9 +12,14 @@ import { getExitPlanImplementationModeDescription, getExitPlanImplementationModeLabel, getExitPlanImplementationModes, + getExitPlanPermissionModes, + getExitPlanPermissionModeLabel, + getExitPlanPermissionModeDescription, isExitPlanModeToolName } from '@/components/ToolCard/exitPlanMode' +type ExitPlanPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' + function SelectionMark(props: { checked: boolean }) { return ( @@ -64,11 +69,13 @@ export function ExitPlanModeFooter(props: { const { haptic } = usePlatform() const permission = props.tool.permission const [selectedMode, setSelectedMode] = useState(null) + const [selectedPermissionMode, setSelectedPermissionMode] = useState('default') const [loading, setLoading] = useState<'approve' | 'deny' | null>(null) const [error, setError] = useState(null) useEffect(() => { setSelectedMode(null) + setSelectedPermissionMode('default') setLoading(null) setError(null) }, [props.tool.id]) @@ -98,7 +105,7 @@ export function ExitPlanModeFooter(props: { setLoading('approve') await run(() => props.api.approvePermission(props.sessionId, permission.id, { - mode: selectedMode === 'clear_context' ? 'acceptEdits' : 'default', + mode: selectedPermissionMode, implementationMode: selectedMode }), 'success') setLoading(null) @@ -149,6 +156,27 @@ export function ExitPlanModeFooter(props: { ))} +
+ {t('tool.exitPlanMode.permissionMode.prompt')} +
+ +
+ {getExitPlanPermissionModes().map((mode) => ( + { + haptic.selection() + setSelectedPermissionMode(mode) + setError(null) + }} + /> + ))} +
+