Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/designs/2025-12-04-ask-user-question-cross-platform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# AskUserQuestion Tool 跨端支持

**Date:** 2025-12-04

## Context
当前 `AskUserQuestion` 工具在 UI 层 (`ApprovalModal.tsx`) 的实现方式是直接修改 `approvalModal.toolUse.params.answers` 参数。这种方式在 Client/Server 分离的架构(跨端场景)下无法工作,因为 Client 端的对象修改无法自动同步回 Server 端执行环境。我们需要一种机制将用户在 UI 上提供的答案传回 Server 端的 `NodeBridge`,进而传给 `Loop` 执行。

## Discussion
在讨论中,主要探讨了以下几个关键点:

1. **协议设计的通用性**:
* **通用方案 (Generic)**:允许 `toolApproval` 返回更新后的参数 (`updatedParams`)。这不仅解决了 `AskUserQuestion` 的问题,也为未来其他工具(如用户在执行前修改写入内容)提供了灵活性。
* **专用方案 (Specific)**:仅传递 `answers` 字段。
* **结论**:选择了 **通用方案**,以支持更广泛的用例。

2. **返回类型设计**:
* **混合返回类型 (Overloaded Return)**:`boolean | { approved: boolean; params?: any }`。
* **标准化对象类型**:`{ approved: boolean; params?: any }`。
* **结论**:选择了 **混合返回类型**,以保持向后兼容性,最小化对现有代码(特别是简单的 `boolean` 返回情况)的影响。

3. **最小改动原则**:
* 用户强调,如果返回值不是对象(即没有参数更新),应保持现状(直接返回 `boolean`),避免不必要的协议开销或破坏现有逻辑。

## Approach
采用通用且向后兼容的方案:
1. **UI 层**:`ApprovalModal` 获取用户答案后,通过 `resolve` 方法将完整的新参数对象传递给 Store。
2. **通信层**:`UIBridge` 和 `NodeBridge` 之间的 `toolApproval` 协议升级,支持返回包含 `params` 的对象。
3. **执行层**:`Loop` 检测 `onToolApprove` 的返回值。如果返回了带有 `params` 的对象,则使用新参数覆盖原有 `toolUse.params`,然后执行工具。

## Architecture

### 1. Loop 层 (`src/loop.ts`)
* **类型定义**:定义 `ToolApprovalResult = boolean | { approved: boolean; params?: any }`。
* **逻辑更新**:在执行工具前,检查 `onToolApprove` 的返回值。如果返回对象且包含 `params`,则更新 `toolUse.params`。

### 2. NodeBridge & Protocol (`src/nodeBridge.ts`)
* **处理逻辑**:在 `session.send` 的 `onToolApprove` 回调中,调用 `messageBus.request('toolApproval')`。
* **返回值处理**:接收 UI 返回的结果。如果有 `params`,则返回对象结构 `{ approved: true, params: ... }`;否则,为了最小化改动,直接返回 `boolean`(如果逻辑允许)或仅在需要时返回对象。

### 3. UI 层 (`src/ui/store.ts` & `src/ui/ApprovalModal.tsx`)
* **Store Action**:更新 `approveToolUse`,允许 `resolve` 接收可选的 `params` 参数。
* **Modal 组件**:`ApprovalModal` 在处理 `AskUserQuestion` 时,不再直接修改 `toolUse` 对象,而是构造一个新的参数对象(包含 `answers`),并将其传递给 `resolve` 函数。

### 4. UIBridge (`src/uiBridge.ts`)
* **Handler**:`toolApproval` handler 将 Store 返回的结果(包含 `approved` 和可选的 `params`)转发给 Server 端。

通过这种架构,数据流变成:
UI (User Answer) -> ApprovalModal -> Store -> UIBridge -> MessageBus -> NodeBridge -> Loop -> Tool Execution (with Answers)。
30 changes: 25 additions & 5 deletions src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ import type {
import type { ModelInfo } from './model';
import { addPromptCache } from './promptCache';
import { getThinkingConfig, type ReasoningEffort } from './thinking-config';
import type { ToolResult, Tools, ToolUse } from './tool';
import type {
ToolApprovalResult,
ToolParams,
ToolResult,
Tools,
ToolUse,
} from './tool';
import { Usage } from './usage';
import { randomUUID } from './utils/randomUUID';
import { safeParseJson } from './utils/safeParseJson';
Expand Down Expand Up @@ -98,6 +104,7 @@ export type ResponseFormat =
export type ThinkingConfig = {
effort: ReasoningEffort;
};

type RunLoopOpts = {
input: string | NormalizedMessage[];
model: ModelInfo;
Expand Down Expand Up @@ -128,7 +135,7 @@ type RunLoopOpts = {
startTime: Date;
endTime: Date;
}) => Promise<void>;
onToolApprove?: (toolUse: ToolUse) => Promise<boolean>;
onToolApprove?: (toolUse: ToolUse) => Promise<ToolApprovalResult>;
onMessage?: OnMessage;
};

Expand Down Expand Up @@ -480,11 +487,24 @@ export async function runLoop(opts: RunLoopOpts): Promise<LoopResult> {
if (opts.onToolUse) {
toolUse = await opts.onToolUse(toolUse as ToolUse);
}
const approved = opts.onToolApprove
? await opts.onToolApprove(toolUse as ToolUse)
: true;
let approved = true;
let updatedParams: ToolParams | undefined = undefined;

if (opts.onToolApprove) {
const approvalResult = await opts.onToolApprove(toolUse as ToolUse);
if (typeof approvalResult === 'object') {
approved = approvalResult.approved;
updatedParams = approvalResult.params;
} else {
approved = approvalResult;
}
}

if (approved) {
toolCallsCount++;
if (updatedParams) {
toolUse.params = { ...toolUse.params, ...updatedParams };
}
let toolResult = await opts.tools.invoke(
toolUse.name,
JSON.stringify(toolUse.params),
Expand Down
14 changes: 12 additions & 2 deletions src/nodeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Project } from './project';
import { query } from './query';
import { SessionConfigManager } from './session';
import { SlashCommandManager } from './slashCommand';
import type { ApprovalCategory, ToolUse } from './tool';
import { getFiles } from './utils/files';
import { listDirectory } from './utils/list';
import { randomUUID } from './utils/randomUUID';
Expand Down Expand Up @@ -1229,12 +1230,21 @@ class NodeHandlerRegistry {
cwd,
});
},
onToolApprove: async ({ toolUse, category }: any) => {
onToolApprove: async ({
toolUse,
category,
}: {
toolUse: ToolUse;
category?: ApprovalCategory;
}) => {
const result = await this.messageBus.request('toolApproval', {
toolUse,
category,
});
return result.approved;

return result.params
? { approved: result.approved, params: result.params }
: result.approved;
},
onStreamResult: async (result: StreamResult) => {
await this.messageBus.emitEvent('streamResult', {
Expand Down
4 changes: 3 additions & 1 deletion src/nodeBridge.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,8 +643,10 @@ type ToolApprovalInput = {
toolUse: ToolUse;
category?: ApprovalCategory;
};

type ToolApprovalOutput = {
approved: any;
approved: boolean;
params?: Record<string, unknown>;
};

// ============================================================================
Expand Down
13 changes: 10 additions & 3 deletions src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { generatePlanSystemPrompt } from './planSystemPrompt';
import { PluginHookType } from './plugin';
import { Session, SessionConfigManager, type SessionId } from './session';
import { generateSystemPrompt } from './systemPrompt';
import type { ApprovalCategory, Tool, ToolUse } from './tool';
import type {
ApprovalCategory,
Tool,
ToolApprovalResult,
ToolUse,
} from './tool';
import { resolveTools, Tools } from './tool';
import type { Usage } from './usage';
import { randomUUID } from './utils/randomUUID';
Expand All @@ -33,7 +38,9 @@ export class Project {
opts: {
model?: string;
onMessage?: (opts: { message: NormalizedMessage }) => Promise<void>;
onToolApprove?: (opts: { toolUse: ToolUse }) => Promise<boolean>;
onToolApprove?: (opts: {
toolUse: ToolUse;
}) => Promise<boolean | ToolApprovalResult>;
onTextDelta?: (text: string) => Promise<void>;
onChunk?: (chunk: any, requestId: string) => Promise<void>;
onStreamResult?: (result: StreamResult) => Promise<void>;
Expand Down Expand Up @@ -135,7 +142,7 @@ export class Project {
onToolApprove?: (opts: {
toolUse: ToolUse;
category?: ApprovalCategory;
}) => Promise<boolean>;
}) => Promise<boolean | ToolApprovalResult>;
onTextDelta?: (text: string) => Promise<void>;
onChunk?: (chunk: any, requestId: string) => Promise<void>;
onStreamResult?: (result: StreamResult) => Promise<void>;
Expand Down
9 changes: 9 additions & 0 deletions src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,12 @@ export function createTool<TSchema extends z.ZodTypeAny>(config: {
approval: config.approval,
};
}

export type ToolParams = Record<string, unknown>;

export type ToolApprovalResult =
| boolean
| {
approved: boolean;
params?: ToolParams;
};
14 changes: 9 additions & 5 deletions src/ui/ApprovalModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,15 @@ export function ApprovalModal() {
<AskQuestionModal
questions={questions}
onResolve={(result, updatedAnswers) => {
if (result === 'approve_once' && updatedAnswers) {
// Update the toolUse params with answers before resolving
approvalModal.toolUse.params.answers = updatedAnswers;
}
approvalModal.resolve(result);
const shouldUpdateParams = updatedAnswers && result !== 'deny';
const newParams: Record<string, unknown> | undefined =
shouldUpdateParams
? {
...approvalModal.toolUse.params,
answers: updatedAnswers,
}
: undefined;
approvalModal.resolve(result, newParams);
}}
/>
);
Expand Down
22 changes: 17 additions & 5 deletions src/ui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ interface AppState {
approvalModal: {
toolUse: ToolUse;
category?: ApprovalCategory;
resolve: (result: ApprovalResult) => Promise<void>;
resolve: (
result: ApprovalResult,
params?: Record<string, unknown>,
) => Promise<void>;
} | null;

memoryModal: {
Expand Down Expand Up @@ -180,7 +183,7 @@ interface AppActions {
}: {
toolUse: ToolUse;
category?: ApprovalCategory;
}) => Promise<ApprovalResult>;
}) => Promise<{ approved: boolean; params?: Record<string, unknown> }>;
showMemoryModal: (rule: string) => Promise<'project' | 'global' | null>;
addToQueue: (message: string) => void;
clearQueue: () => void;
Expand Down Expand Up @@ -931,12 +934,18 @@ export const useAppStore = create<AppStore>()(
category?: ApprovalCategory;
}) => {
const { bridge, cwd, sessionId } = get();
return new Promise<boolean>((resolve) => {
return new Promise<{
approved: boolean;
params?: Record<string, unknown>;
}>((resolve) => {
set({
approvalModal: {
toolUse,
category,
resolve: async (result: ApprovalResult) => {
resolve: async (
result: ApprovalResult,
params?: Record<string, unknown>,
) => {
set({ approvalModal: null });
const isApproved = result !== 'deny';
if (result === 'approve_always_edit') {
Expand All @@ -952,7 +961,10 @@ export const useAppStore = create<AppStore>()(
approvalTool: toolUse.name,
});
}
resolve(isApproved);
resolve({
approved: isApproved,
params: isApproved ? params : undefined,
});
},
},
});
Expand Down
5 changes: 4 additions & 1 deletion src/uiBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,10 @@ class UIHandlerRegistry {
toolUse,
category,
});
return { approved: result };
return {
approved: result.approved,
params: result.params,
};
},
);

Expand Down
Loading