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
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,9 @@ describe('PermissionController', () => {
name: 'ask_user_question',
args: { questions: [] } as Record<string, unknown>,
},
invocation: {
requiresUserInteraction: () => true,
},
confirmationDetails: {
type: 'ask_user_question',
title: 'Please answer',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,15 @@ export class PermissionController extends BaseController {
const behavior = String(payload['behavior'] || '').toLowerCase();

if (behavior === 'allow') {
if (requiresUserInteraction) {
// exit_plan_mode approves through the dialog alone: its onConfirm
// takes no payload, and the approved plan must not be replaced by
// the host's updatedInput. Any other requiresUserInteraction tool
// (e.g. ask_user_question) must take the updatedInput path below —
// that channel carries the user's answers.
if (
requiresUserInteraction &&
toolCall.request.name === ToolNames.EXIT_PLAN_MODE
) {
Comment on lines +582 to +585

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This name check is the whole fix of the stream-json answer-drop regression — the unscoped if (requiresUserInteraction) at the base commit confirmed with no payload, silently discarding the answers the SDK host had just collected — yet nothing in the code says why exit_plan_mode alone takes the payload-less branch. The rationale currently lives only in two tests, so a future maintainer "simplifying" the seemingly redundant toolCall.request.name === ToolNames.EXIT_PLAN_MODE condition re-broadens the guard and drops stream-json answers again — the exact regression this PR fixes. Conversely, someone adding a third outcome-only interactive tool has no in-code signal about which path it must take, and host updatedInput can silently overwrite the args the user approved. A short comment pins the contract:

Suggested change
if (
requiresUserInteraction &&
toolCall.request.name === ToolNames.EXIT_PLAN_MODE
) {
// exit_plan_mode approves through the dialog alone: its onConfirm takes
// no payload and the approved plan must not be replaced by the host's
// updatedInput. Any other requiresUserInteraction tool (e.g.
// ask_user_question) must take the updatedInput path below — that
// channel carries the user's answers.
if (
requiresUserInteraction &&
toolCall.request.name === ToolNames.EXIT_PLAN_MODE
) {
中文说明

这个工具名判断正是 stream-json 回答丢失回归的全部修复——base 提交上未加限定的 if (requiresUserInteraction) 会不带 payload 直接确认,悄悄丢掉 SDK 宿主刚收集到的答案——但代码里没有任何地方说明为什么只有 exit_plan_mode 走这条不带 payload 的分支。理由目前只存在于两个测试里:未来某位维护者"简化"这个看似冗余的 toolCall.request.name === ToolNames.EXIT_PLAN_MODE 条件,就会重新放宽守卫、再次丢掉 stream-json 的答案——正是本 PR 修复的回归。反过来,若有人新增第三个"只要结果"的交互工具,也没有任何代码内信号告诉它该走哪条路径,宿主的 updatedInput 可能悄悄覆盖用户批准的参数。一条简短注释即可锁定该约定。

— qwen3.8-max via Qwen Code /review (v0.22.2)

await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
Expand Down
105 changes: 105 additions & 0 deletions packages/core/src/core/permissionFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import {
isPlanModeBlocked,
isAutoEditApproved,
} from './permissionFlow.js';
import { AskUserQuestionTool } from '../tools/askUserQuestion.js';
import { PermissionManager } from '../permissions/permission-manager.js';
import { applySkillAllowedTools } from '../tools/skill-utils.js';

// Mock types for testing
const mockConfig = (overrides: Partial<Config> = {}): Config =>
Expand Down Expand Up @@ -217,6 +220,108 @@ describe('evaluatePermissionFlow', () => {
});
});

describe('evaluatePermissionFlow with ask_user_question', () => {
const questions = [
{
question: 'Which check defines success?',
header: 'Check',
options: [
{ label: 'npm test', description: 'exit code 0' },
{ label: 'npm run lint', description: 'no warnings' },
],
multiSelect: false,
},
];

const askConfig = (interactive: boolean) =>
({
isInteractive: vi.fn().mockReturnValue(interactive),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getTargetDir: vi.fn().mockReturnValue('/test'),
getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
getInputFormat: vi.fn().mockReturnValue(undefined),
}) as unknown as Config;

const pmWithSkillGrant = () => {
const pm = new PermissionManager({
getPermissionsAllow: () => [],
getPermissionsAsk: () => [],
getPermissionsDeny: () => [],
getApprovalMode: () => ApprovalMode.DEFAULT,
});
pm.initialize();
// Exactly what loading a skill whose SKILL.md lists
// `allowedTools: [ask_user_question]` does to the session.
applySkillAllowedTools(pm, [ToolNames.ASK_USER_QUESTION]);
return pm;
};

it("keeps the dialog when a skill's allowedTools grant would otherwise allow the tool", async () => {
const config = askConfig(true);
const pm = pmWithSkillGrant();
const invocation = new AskUserQuestionTool(config).build({ questions });

const result = await evaluatePermissionFlow(
{ ...config, getPermissionManager: () => pm } as unknown as Config,
invocation,
ToolNames.ASK_USER_QUESTION,
{ questions },
);

// The grant did override the 'ask' default at L4 …
expect(result.defaultPermission).toBe('ask');
expect(await pm.evaluate(result.pmCtx)).toBe('allow');
// … but the invocation still reaches the user, in every approval mode.
expect(result.requiresUserInteraction).toBe(true);
expect(result.finalPermission).toBe('ask');
expect(
needsConfirmation(
result.finalPermission,
ApprovalMode.YOLO,
ToolNames.ASK_USER_QUESTION,
result.requiresUserInteraction,
),
).toBe(true);
});

it('still lets headless runs skip the tool, where nothing can prompt', async () => {
const config = askConfig(false);
const pm = pmWithSkillGrant();
const invocation = new AskUserQuestionTool(config).build({ questions });

const result = await evaluatePermissionFlow(
{ ...config, getPermissionManager: () => pm } as unknown as Config,
invocation,
ToolNames.ASK_USER_QUESTION,
{ questions },
);

expect(result.requiresUserInteraction).toBe(false);
expect(result.finalPermission).toBe('allow');
});

it('preserves an explicit deny rule for ask_user_question', async () => {
const config = askConfig(true);
const pm = new PermissionManager({
getPermissionsAllow: () => [],
getPermissionsAsk: () => [],
getPermissionsDeny: () => [ToolNames.ASK_USER_QUESTION],
getApprovalMode: () => ApprovalMode.DEFAULT,
});
pm.initialize();
const invocation = new AskUserQuestionTool(config).build({ questions });

const result = await evaluatePermissionFlow(
{ ...config, getPermissionManager: () => pm } as unknown as Config,
invocation,
ToolNames.ASK_USER_QUESTION,
{ questions },
);

expect(result.finalPermission).toBe('deny');
});
});

describe('needsConfirmation', () => {
it('should return false for YOLO mode non-ask_user_question tools', () => {
expect(needsConfirmation('ask', ApprovalMode.YOLO, 'shell')).toBe(false);
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/tools/askUserQuestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,47 @@ describe('AskUserQuestionTool', () => {
});
});

describe('requiresUserInteraction', () => {
const params = {
questions: [
{
question: 'Pick a framework?',
header: 'Framework',
options: [
{ label: 'React', description: 'A JavaScript library' },
{ label: 'Vue', description: 'Progressive framework' },
],
multiSelect: false,
},
],
};

it('requires the dialog in interactive mode so allow rules cannot skip it', () => {
// A bare `ask_user_question` allow rule (a skill's `allowedTools`
// grant, permissions.allow, "always allow") overrides the 'ask'
// default at L4. Without this flag the scheduler would then run the
// tool with no dialog and execute() would report "declined".
const invocation = tool.build(params);
expect(invocation.requiresUserInteraction?.()).toBe(true);
});

it('requires the dialog for ACP hosts that run non-interactively', () => {
(mockConfig.isInteractive as Mock).mockReturnValue(false);
(mockConfig.getInputFormat as Mock).mockReturnValue('stream-json');
expect(tool.build(params).requiresUserInteraction?.()).toBe(true);

(mockConfig.getInputFormat as Mock).mockReturnValue(undefined);
(mockConfig.getExperimentalZedIntegration as Mock).mockReturnValue(true);
expect(tool.build(params).requiresUserInteraction?.()).toBe(true);
});

it('does not require a dialog in headless mode, where nothing can prompt', () => {
(mockConfig.isInteractive as Mock).mockReturnValue(false);
const invocation = tool.build(params);
expect(invocation.requiresUserInteraction?.()).toBe(false);
});
});

describe('execute', () => {
it('should return error in non-interactive mode', async () => {
(mockConfig.isInteractive as Mock).mockReturnValue(false);
Expand Down
41 changes: 28 additions & 13 deletions packages/core/src/tools/askUserQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { FunctionDeclaration } from '@google/genai';
import type { Config } from '../config/config.js';
import { ToolDisplayNames, ToolNames } from './tool-names.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { InputFormat } from '../output/types.js';
import { resolveInteractionMode } from '../core/prompts.js';

const debugLogger = createDebugLogger('ASK_USER_QUESTION');

Expand Down Expand Up @@ -171,23 +171,44 @@ class AskUserQuestionToolInvocation extends BaseToolInvocation<
return `Ask user ${questionCount} question${questionCount > 1 ? 's' : ''}`;
}

/**
* Whether a host is present that can put the questions in front of the
* user. ACP hosts (VSCode extension, Zed, stream-json clients) run in
* non-interactive mode but still collect answers through the
* confirmation channel.
*/
private canCollectAnswers(): boolean {
return resolveInteractionMode(this._config) !== 'headless';
}
Comment on lines +180 to +182

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] canCollectAnswers() is a fifth copy of the host-capability predicate. resolveInteractionMode(config) !== 'headless' in packages/core/src/core/prompts.ts already expresses exactly this ("is there a host that can put the questions in front of the user"), and config.ts already uses it (supportsUserInteraction) to gate the registration of ask_user_question itself — so the registration gate and this runtime gate are two separately written copies of the same predicate. If a new answerable host or input format is added, config.ts and the scheduler copies (enterPlanMode.ts, coreToolScheduler.ts) can be updated while canCollectAnswers() is missed — or vice versa: the tool then registers and the system prompt encourages asking, but getDefaultPermission() returns 'allow', requiresUserInteraction() returns false, and execute() refuses with "Cannot ask user questions in non-interactive mode without ACP support" — the exact drift resolveInteractionMode's own comment warns about. Delegating keeps one source of truth (needs the resolveInteractionMode import from ../core/prompts.js):

Suggested change
private canCollectAnswers(): boolean {
const isAcpMode =
this._config.getExperimentalZedIntegration() ||
this._config.getInputFormat() === InputFormat.STREAM_JSON;
return this._config.isInteractive() || isAcpMode;
}
private canCollectAnswers(): boolean {
return resolveInteractionMode(this._config) !== 'headless';
}

If you apply this, run the requiresUserInteraction suite in src/tools/askUserQuestion.test.ts as the mutation check — it pins the mode truth table (interactive / stream-json / Zed → true, headless → false) and goes red if the delegation breaks that equivalence.

中文说明

canCollectAnswers() 是"宿主能否弹出对话框"这一谓词的第五份拷贝。packages/core/src/core/prompts.ts 里的 resolveInteractionMode(config) !== 'headless' 已经精确表达了同一语义,而且 config.ts 已经用它(supportsUserInteraction)来控制 ask_user_question 本身的注册——注册开关与这里的运行时开关现在是同一谓词的两份独立实现。将来新增一种可作答的宿主或输入格式时,config.ts 和调度器里的拷贝(enterPlanMode.tscoreToolScheduler.ts)可能更新了而 canCollectAnswers() 被漏掉(或反过来):工具照常注册、系统提示词鼓励提问,但 getDefaultPermission() 返回 'allow'requiresUserInteraction() 返回 falseexecute() 报"Cannot ask user questions in non-interactive mode without ACP support"——正是 resolveInteractionMode 自身注释所警告的漂移。委托给它即可保持单一事实来源(需要从 ../core/prompts.js 导入 resolveInteractionMode)。

若应用该修改,请把 src/tools/askUserQuestion.test.tsrequiresUserInteraction 套件作为变异检查重新运行——它锁定了模式真值表(交互 / stream-json / Zed → true,headless → false),一旦委托破坏等价性就会变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)


/**
* ask_user_question always requires user confirmation so the user can
* provide answers. In non-interactive mode without ACP support, we skip
* confirmation (and subsequently skip execution).
*/
override async getDefaultPermission(): Promise<PermissionDecision> {
const isAcpMode =
this._config.getExperimentalZedIntegration() ||
this._config.getInputFormat() === InputFormat.STREAM_JSON;

if (!this._config.isInteractive() && !isAcpMode) {
if (!this.canCollectAnswers()) {
// Non-interactive + no ACP: skip entirely
return 'allow';
}
return 'ask';
}

/**
* The confirmation dialog IS this tool: the answers are collected through
* `onConfirm`, so an approval that skips the dialog does not "allow" the
* tool, it silently answers "declined" on the user's behalf. Permission
* rules and automatic approval modes must therefore never satisfy it —
* a bare `ask_user_question` allow rule (a skill's `allowedTools` grant,
* `permissions.allow`, an "always allow" answer) would otherwise override
* the 'ask' default at L4 and the scheduler would run the tool with no
* dialog ever shown. Headless runs stay as they were: nothing can prompt
* there, and `execute()` reports that instead.
*/
override requiresUserInteraction(): boolean {
return this.canCollectAnswers();
}

override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolAskUserQuestionConfirmationDetails> {
Expand Down Expand Up @@ -222,14 +243,8 @@ class AskUserQuestionToolInvocation extends BaseToolInvocation<

async execute(_signal: AbortSignal): Promise<ToolResult> {
try {
// Check if we're in a mode that supports user interaction
// ACP mode (VSCode extension, etc.) uses non-interactive mode but can still collect user input
const isAcpMode =
this._config.getExperimentalZedIntegration() ||
this._config.getInputFormat() === InputFormat.STREAM_JSON;

// In non-interactive mode without ACP support, we cannot collect user input
if (!this._config.isInteractive() && !isAcpMode) {
if (!this.canCollectAnswers()) {
const errorMessage =
'Cannot ask user questions in non-interactive mode without ACP support. Please run in interactive mode or enable ACP mode to use this tool.';
return {
Expand Down
Loading