Merge pull request #2763 from QwenLM/fix/2754-allow-webfetch-in-plan-mode

fix: allow web fetch approvals in plan mode
This commit is contained in:
tanzhenxin 2026-04-01 15:41:03 +08:00 committed by GitHub
commit 311f971ba7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 170 additions and 37 deletions

View file

@ -2480,6 +2480,70 @@ describe('CoreToolScheduler plan mode with ask_user_question', () => {
}
});
it('should allow info confirmation tools in plan mode after approval', async () => {
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
const infoTool = new MockTool({
name: 'web_fetch',
getDefaultPermission: async () => 'ask',
getConfirmationDetails: async () => ({
type: 'info' as const,
title: 'Confirm Web Fetch',
prompt: 'Fetch https://example.com/docs',
urls: ['https://example.com/docs'],
onConfirm: onConfirmSpy,
}),
execute: async () => ({
llmContent: 'Fetched docs',
returnDisplay: 'Fetched docs',
}),
});
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const scheduler = createPlanModeScheduler(
infoTool,
onAllToolCallsComplete,
onToolCallsUpdate,
);
const abortController = new AbortController();
const request = {
callId: '1',
name: 'web_fetch',
args: {
url: 'https://example.com/docs',
prompt: 'Summarize the API docs',
},
isClientInitiated: false,
prompt_id: 'prompt-plan-info',
};
await scheduler.schedule([request], abortController.signal);
const awaitingCall = (await waitForStatus(
onToolCallsUpdate,
'awaiting_approval',
)) as WaitingToolCall;
expect(awaitingCall.confirmationDetails.type).toBe('info');
await awaitingCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
await vi.waitFor(() => {
expect(onAllToolCallsComplete).toHaveBeenCalled();
});
expect(onConfirmSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
undefined,
);
const completedCalls = onAllToolCallsComplete.mock
.calls[0][0] as ToolCall[];
expect(completedCalls[0].status).toBe('success');
});
it('should handle user cancellation of ask_user_question in plan mode', async () => {
const mockTool = createAskUserQuestionMockTool();
const onAllToolCallsComplete = vi.fn();

View file

@ -903,6 +903,7 @@ export class CoreToolScheduler {
// it must bypass both YOLO auto-approve and plan-mode blocking.
const isAskUserQuestionTool =
reqInfo.name === ToolNames.ASK_USER_QUESTION;
let confirmationDetails: ToolCallConfirmationDetails | undefined;
if (approvalMode === ApprovalMode.YOLO && !isAskUserQuestionTool) {
this.setToolCallOutcome(
@ -910,30 +911,33 @@ export class CoreToolScheduler {
ToolConfirmationOutcome.ProceedAlways,
);
this.setStatusInternal(reqInfo.callId, 'scheduled');
} else if (
isPlanMode &&
!isExitPlanModeTool &&
!isAskUserQuestionTool
) {
this.setStatusInternal(reqInfo.callId, 'error', {
callId: reqInfo.callId,
responseParts: convertToFunctionResponse(
reqInfo.name,
reqInfo.callId,
getPlanModeSystemReminder(),
),
resultDisplay: 'Plan mode blocked a non-read-only tool call.',
error: undefined,
errorType: undefined,
});
} else {
// Get confirmation details from the tool
const confirmationDetails =
confirmationDetails =
await invocation.getConfirmationDetails(signal);
// ── Centralised rule injection ──────────────────────────────────
injectPermissionRulesIfMissing(confirmationDetails, pmCtx);
if (
isPlanMode &&
!isExitPlanModeTool &&
!isAskUserQuestionTool &&
confirmationDetails.type !== 'info'
) {
this.setStatusInternal(reqInfo.callId, 'error', {
callId: reqInfo.callId,
responseParts: convertToFunctionResponse(
reqInfo.name,
reqInfo.callId,
getPlanModeSystemReminder(),
),
resultDisplay: 'Plan mode blocked a non-read-only tool call.',
error: undefined,
errorType: undefined,
});
continue;
}
// AUTO_EDIT mode: auto-approve edit-like and info tools
if (
approvalMode === ApprovalMode.AUTO_EDIT &&
@ -990,14 +994,14 @@ export class CoreToolScheduler {
if (resolution.status === 'accepted') {
this.handleConfirmationResponse(
reqInfo.callId,
confirmationDetails.onConfirm,
confirmationDetails!.onConfirm,
ToolConfirmationOutcome.ProceedOnce,
signal,
);
} else {
this.handleConfirmationResponse(
reqInfo.callId,
confirmationDetails.onConfirm,
confirmationDetails!.onConfirm,
ToolConfirmationOutcome.Cancel,
signal,
);