From 8705ee5d17b3a9ea2e26c042e96ed5aae2d673db Mon Sep 17 00:00:00 2001 From: tianyuan <2720711917@qq.com> Date: Fri, 10 Jul 2026 15:04:31 +0800 Subject: [PATCH 1/4] feat(cli): forward ask_user_question answers from SDK can_use_tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK-hosted agents could receive ask_user_question calls through the can_use_tool callback and approve them, but the user's answers never reached the tool: the CLI called onConfirm(ProceedOnce) with no payload, so the tool read an empty answers map and the model never got the decisions. Route updatedInput.answers from the SDK's allow response into the tool confirmation payload so the collected answers reach the tool. Reuses the existing updatedInput channel — no new SDK API or types. Document the pattern in the TypeScript and Python SDK READMEs. --- .../controllers/permissionController.test.ts | 83 +++++++++++++++++++ .../controllers/permissionController.ts | 43 ++++++++-- packages/sdk-python/README.md | 33 ++++++++ packages/sdk-typescript/README.md | 39 +++++++++ 4 files changed, 192 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts index ec03ad28471..d8936126e0f 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts @@ -97,6 +97,89 @@ describe('PermissionController', () => { }); }); + it('routes ask_user_question answers from updatedInput into the confirmation payload', async () => { + const context = createContext(120_000); + const controller = new PermissionController( + context, + createRegistry(), + 'PermissionController', + ); + const answers = { '0': 'PostgreSQL', '1': 'REST' }; + vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({ + subtype: 'success', + request_id: 'request-answers', + response: { + behavior: 'allow', + updatedInput: { questions: [], answers }, + }, + }); + const onConfirm = vi.fn(); + + controller.getToolCallUpdateCallback()([ + { + status: 'awaiting_approval', + request: { + callId: 'tool-call-answers', + name: 'ask_user_question', + args: { questions: [] }, + }, + confirmationDetails: { + type: 'ask_user_question', + title: 'Please answer', + onConfirm, + }, + }, + ]); + + await vi.waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + expect.objectContaining({ answers }), + ); + }); + }); + + it('omits answers from the payload when updatedInput has none', async () => { + const context = createContext(120_000); + const controller = new PermissionController( + context, + createRegistry(), + 'PermissionController', + ); + vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({ + subtype: 'success', + request_id: 'request-no-answers', + response: { + behavior: 'allow', + updatedInput: { command: 'ls -a' }, + }, + }); + const onConfirm = vi.fn(); + + controller.getToolCallUpdateCallback()([ + { + status: 'awaiting_approval', + request: { + callId: 'tool-call-no-answers', + name: 'run_shell_command', + args: { command: 'ls' }, + }, + confirmationDetails: { + type: 'exec', + title: 'Run command', + onConfirm, + }, + }, + ]); + + await vi.waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { updatedInput: { command: 'ls -a' } }, + ); + }); + }); + it('uses default timeout when SDK canUseTool timeout is undefined', async () => { const context = createContext(); // undefined timeout const controller = new PermissionController( diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index 4474554eed0..835f6986848 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -555,14 +555,45 @@ export class PermissionController extends BaseController { const behavior = String(payload['behavior'] || '').toLowerCase(); if (behavior === 'allow') { - // Handle updated input if provided + // Handle updated input if provided. The SDK's `can_use_tool` + // callback returns `updatedInput` — the (possibly sanitised) + // tool args the host wants executed. For most tools this simply + // overrides `request.args`. For `ask_user_question` the host also + // uses this channel to deliver the user's answers: it returns + // `{ ...originalInput, answers }`, and those answers must reach the + // tool via the confirmation payload (`payload.answers`) — the tool + // reads answers from there, not from `request.args`. const updatedInput = payload['updatedInput']; - if (updatedInput && typeof updatedInput === 'object') { - toolCall.request.args = updatedInput as Record; + let confirmationPayload: ToolConfirmationPayload | undefined; + if ( + updatedInput && + typeof updatedInput === 'object' && + !Array.isArray(updatedInput) + ) { + const updatedInputObj = updatedInput as Record; + toolCall.request.args = updatedInputObj; + + const answers = updatedInputObj['answers']; + confirmationPayload = { + updatedInput: updatedInputObj, + ...(answers && + typeof answers === 'object' && + !Array.isArray(answers) + ? { answers: answers as Record } + : {}), + }; + } + + if (confirmationPayload) { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + confirmationPayload, + ); + } else { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + ); } - await toolCall.confirmationDetails.onConfirm( - ToolConfirmationOutcome.ProceedOnce, - ); } else { // Extract cancel message from response if available const cancelMessage = diff --git a/packages/sdk-python/README.md b/packages/sdk-python/README.md index 3f324a42129..6deca2011f4 100644 --- a/packages/sdk-python/README.md +++ b/packages/sdk-python/README.md @@ -286,6 +286,39 @@ The `context` argument includes `cancel_event`, `suggestions`, and `can_use_tool` must be an `async def` callback accepting `(tool_name, tool_input, context)`. `stderr` must accept a single `str`. +### Handling `ask_user_question` + +When the model needs a decision from the user it calls the built-in +`ask_user_question` tool. This flows through the same `can_use_tool` +callback: `tool_input` carries a `questions` list, and you return the +collected answers via `updatedInput["answers"]`. `answers` is a dict keyed +by the question's index (as a string), where each value is the label of the +chosen option (or free-form text when the user picks "Other"). + +```python +async def can_use_tool(tool_name, tool_input, context): + if tool_name == "ask_user_question": + questions = tool_input["questions"] + + # Present the questions to the user however your app sees fit, + # then build an index-keyed map of their answers. + answers = {} + for index, question in enumerate(questions): + answers[str(index)] = await prompt_user_to_choose(question) + + # Return the answers through updatedInput["answers"] — the CLI + # forwards them to the tool so the model receives the decisions. + return { + "behavior": "allow", + "updatedInput": {**tool_input, "answers": answers}, + } + + return {"behavior": "allow", "updatedInput": tool_input} +``` + +If you return `allow` without any `answers`, the tool reports that no answer +was provided; return `deny` to signal the user declined. + ## Runtime Controls Control methods can be called while a session is active: diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 027e697e71c..43896be759d 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -314,6 +314,45 @@ const result = query({ }); ``` +### Handling `ask_user_question` + +When the model needs a decision from the user it calls the built-in +`ask_user_question` tool. The SDK surfaces this through the same +`canUseTool` callback: the tool input contains a `questions` array, and you +return the collected answers via `updatedInput.answers`. `answers` is an +object keyed by the question's index (as a string), where each value is the +label of the chosen option (or free-form text when the user picks "Other"). + +```typescript +import { query, type CanUseTool } from '@qwen-code/sdk'; + +const canUseTool: CanUseTool = async (toolName, input, { signal }) => { + if (toolName === 'ask_user_question') { + const questions = input.questions as Array<{ + question: string; + header: string; + options: Array<{ label: string; description: string }>; + }>; + + // Present the questions to the user however your app sees fit, then + // build an index-keyed map of their answers. + const answers: Record = {}; + for (let i = 0; i < questions.length; i++) { + answers[String(i)] = await promptUserToChoose(questions[i]); + } + + // Return the answers through `updatedInput.answers` — the CLI forwards + // them to the tool so the model receives the user's decisions. + return { behavior: 'allow', updatedInput: { ...input, answers } }; + } + + return { behavior: 'allow', updatedInput: input }; +}; +``` + +> If you return `allow` without any `answers`, the tool reports that no +> answer was provided; return `deny` to signal the user declined. + ### With External MCP Servers ```typescript From 7cd5f91fd19cb7845043a3ddd752bd0627acac62 Mon Sep 17 00:00:00 2001 From: tianyuan <2720711917@qq.com> Date: Fri, 10 Jul 2026 22:31:29 +0800 Subject: [PATCH 2/4] fix(cli): forward ask_user_question answers on teammate approval path Address review feedback on #6655: - handleTeammateApproval now mirrors the leader path and promotes the user's answers from updatedInput into the confirmation payload, so ask_user_question calls approved through a teammate no longer drop the user's choices (wenshao). - Extract a shared buildAllowConfirmationPayload helper used by both the leader and teammate paths, and only promote `answers` for ask_user_question so a same-named field on any other tool's input can't leak into the payload. - Add tests for the teammate path and the defensive guards (array updatedInput, array/null/empty answers, foreign answers field). --- .../controllers/permissionController.test.ts | 168 ++++++++++++++++++ .../controllers/permissionController.ts | 78 +++++--- 2 files changed, 218 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts index d8936126e0f..bd1bb21e3b6 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts @@ -180,6 +180,108 @@ describe('PermissionController', () => { }); }); + it('does not promote a same-named answers field for non-ask_user_question tools', async () => { + const context = createContext(120_000); + const controller = new PermissionController( + context, + createRegistry(), + 'PermissionController', + ); + vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({ + subtype: 'success', + request_id: 'request-foreign-answers', + response: { + behavior: 'allow', + // A non-ask_user_question tool happens to carry an `answers` field; + // it must not leak into the confirmation payload. + updatedInput: { command: 'ls', answers: { '0': 'leak' } }, + }, + }); + const onConfirm = vi.fn(); + + controller.getToolCallUpdateCallback()([ + { + status: 'awaiting_approval', + request: { + callId: 'tool-call-foreign-answers', + name: 'run_shell_command', + args: { command: 'ls' }, + }, + confirmationDetails: { + type: 'exec', + title: 'Run command', + onConfirm, + }, + }, + ]); + + await vi.waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { updatedInput: { command: 'ls', answers: { '0': 'leak' } } }, + ); + }); + expect(onConfirm).not.toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + expect.objectContaining({ answers: expect.anything() }), + ); + }); + + it.each([ + ['updatedInput is an array', ['ls'], undefined], + ['answers is an array', { questions: [], answers: ['x'] }, undefined], + ['answers is null', { questions: [], answers: null }, undefined], + ['answers is an empty object', { questions: [], answers: {} }, {}], + ])( + 'omits answers from the payload when %s', + async (_desc, updatedInput, expectedAnswers) => { + const context = createContext(120_000); + const controller = new PermissionController( + context, + createRegistry(), + 'PermissionController', + ); + vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({ + subtype: 'success', + request_id: 'request-guard', + response: { behavior: 'allow', updatedInput }, + }); + const onConfirm = vi.fn(); + + controller.getToolCallUpdateCallback()([ + { + status: 'awaiting_approval', + request: { + callId: 'tool-call-guard', + name: 'ask_user_question', + args: { questions: [] }, + }, + confirmationDetails: { + type: 'ask_user_question', + title: 'Please answer', + onConfirm, + }, + }, + ]); + + await vi.waitFor(() => { + expect(onConfirm).toHaveBeenCalled(); + }); + + const [outcome, payload] = onConfirm.mock.calls[0]; + expect(outcome).toBe(ToolConfirmationOutcome.ProceedOnce); + if (Array.isArray(updatedInput)) { + // An array updatedInput is rejected wholesale — plain confirm. + expect(payload).toBeUndefined(); + } else if (expectedAnswers === undefined) { + expect(payload).toEqual({ updatedInput }); + expect(payload).not.toHaveProperty('answers'); + } else { + expect(payload).toEqual({ updatedInput, answers: expectedAnswers }); + } + }, + ); + it('uses default timeout when SDK canUseTool timeout is undefined', async () => { const context = createContext(); // undefined timeout const controller = new PermissionController( @@ -267,6 +369,72 @@ describe('PermissionController', () => { }); }); + it('forwards ask_user_question answers to a teammate approval', async () => { + const context = createContext(120_000); + const controller = new PermissionController( + context, + createRegistry(), + 'PermissionController', + ); + const answers = { '0': 'PostgreSQL', '1': 'REST' }; + vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({ + subtype: 'success', + request_id: 'teammate-request', + response: { + behavior: 'allow', + updatedInput: { questions: [], answers }, + }, + }); + const respond = vi.fn().mockResolvedValue(undefined); + + await controller.handleTeammateApproval({ + teammateName: 'worker', + toolName: 'ask_user_question', + toolInput: { questions: [] }, + respond, + timestamp: 123, + }); + + expect(respond).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + expect.objectContaining({ answers }), + ); + }); + + it('does not promote a same-named answers field for a non-ask_user_question teammate approval', async () => { + const context = createContext(120_000); + const controller = new PermissionController( + context, + createRegistry(), + 'PermissionController', + ); + vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({ + subtype: 'success', + request_id: 'teammate-request-foreign', + response: { + behavior: 'allow', + updatedInput: { command: 'ls', answers: { '0': 'leak' } }, + }, + }); + const respond = vi.fn().mockResolvedValue(undefined); + + await controller.handleTeammateApproval({ + teammateName: 'worker', + toolName: 'run_shell_command', + toolInput: { command: 'ls' }, + respond, + timestamp: 456, + }); + + expect(respond).toHaveBeenCalledWith(ToolConfirmationOutcome.ProceedOnce, { + updatedInput: { command: 'ls', answers: { '0': 'leak' } }, + }); + expect(respond).not.toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + expect.objectContaining({ answers: expect.anything() }), + ); + }); + it('omits modify suggestions when edit confirmation hides modify actions', () => { const controller = new PermissionController( createContext(), diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index 835f6986848..f798fec47f1 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -388,6 +388,42 @@ export class PermissionController extends BaseController { }; } + /** + * Build the confirmation payload for an approved (`allow`) tool call. + * + * `updatedInput` carries the (possibly sanitised) tool args the host + * wants executed. For `ask_user_question` the host also delivers the + * user's answers on this channel as `updatedInput.answers`; those + * answers must reach the tool via `payload.answers` (the tool reads + * them from there, not from its args). Answers are promoted only for + * `ask_user_question`, so a same-named `answers` field on any other + * tool's input can never leak into the confirmation payload. + * + * Returns `undefined` when the host sent no usable `updatedInput`, so + * callers fall back to a plain single-argument confirmation. + */ + private buildAllowConfirmationPayload( + toolName: string, + updatedInput: unknown, + ): ToolConfirmationPayload | undefined { + if ( + !updatedInput || + typeof updatedInput !== 'object' || + Array.isArray(updatedInput) + ) { + return undefined; + } + const updatedInputObj = updatedInput as Record; + const answers = + toolName === 'ask_user_question' ? updatedInputObj['answers'] : undefined; + return { + updatedInput: updatedInputObj, + ...(answers && typeof answers === 'object' && !Array.isArray(answers) + ? { answers: answers as Record } + : {}), + }; + } + /** * Handle a teammate tool approval request routed via the * TEAMMATE_APPROVAL_REQUEST team event. Stream-json only — @@ -447,14 +483,13 @@ export class PermissionController extends BaseController { // args and the host's policy is silently bypassed. The // leader's same-process path mutates `request.args` // directly; teammates can't reach across process so the - // payload carries the override instead. - const updatedInput = payload['updatedInput']; - const respondPayload: ToolConfirmationPayload | undefined = - updatedInput && - typeof updatedInput === 'object' && - !Array.isArray(updatedInput) - ? { updatedInput: updatedInput as Record } - : undefined; + // payload carries the override instead. For + // `ask_user_question` this same payload also carries the + // user's answers, mirroring the leader path. + const respondPayload = this.buildAllowConfirmationPayload( + event.toolName, + payload['updatedInput'], + ); await event.respond( ToolConfirmationOutcome.ProceedOnce, respondPayload, @@ -563,28 +598,15 @@ export class PermissionController extends BaseController { // `{ ...originalInput, answers }`, and those answers must reach the // tool via the confirmation payload (`payload.answers`) — the tool // reads answers from there, not from `request.args`. - const updatedInput = payload['updatedInput']; - let confirmationPayload: ToolConfirmationPayload | undefined; - if ( - updatedInput && - typeof updatedInput === 'object' && - !Array.isArray(updatedInput) - ) { - const updatedInputObj = updatedInput as Record; - toolCall.request.args = updatedInputObj; - - const answers = updatedInputObj['answers']; - confirmationPayload = { - updatedInput: updatedInputObj, - ...(answers && - typeof answers === 'object' && - !Array.isArray(answers) - ? { answers: answers as Record } - : {}), - }; - } + const confirmationPayload = this.buildAllowConfirmationPayload( + toolCall.request.name, + payload['updatedInput'], + ); if (confirmationPayload) { + // Override the tool's args in-process with the host's + // sanitised input before confirming. + toolCall.request.args = confirmationPayload.updatedInput ?? {}; await toolCall.confirmationDetails.onConfirm( ToolConfirmationOutcome.ProceedOnce, confirmationPayload, From d78d7ac8ed9b20f09c923e7b6cf4682b0ff832d1 Mon Sep 17 00:00:00 2001 From: tianyuan <2720711917@qq.com> Date: Fri, 10 Jul 2026 23:42:54 +0800 Subject: [PATCH 3/4] test(web-shell): stub Range client-rect methods to fix flaky CI CodeMirror's async measure pass (scheduled via requestAnimationFrame) calls getClientRects()/getBoundingClientRect() on a text Range. jsdom implements these on Element but not on Range, so the call throws "textRange(...).getClientRects is not a function" from a rAF callback after the test completed. Vitest surfaces it as an unhandled error and fails the whole run with exit code 1 even though every assertion passed (seen intermittently in useComposerCore.dom.test.tsx). Polyfill both methods on Range.prototype in the shared test setup, mirroring the existing ResizeObserver/scrollIntoView stubs. --- packages/web-shell/client/test/setup.ts | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/web-shell/client/test/setup.ts b/packages/web-shell/client/test/setup.ts index 79d2dfd9d7a..be278725f65 100644 --- a/packages/web-shell/client/test/setup.ts +++ b/packages/web-shell/client/test/setup.ts @@ -24,6 +24,39 @@ if ( globalWithDom.Element.prototype.scrollIntoView = () => {}; } +// jsdom implements getClientRects()/getBoundingClientRect() on Element but not +// on Range. CodeMirror's async measure pass (scheduled via requestAnimationFrame) +// calls them on a text Range, so without this stub it throws +// "textRange(...).getClientRects is not a function" from a rAF callback after a +// test has completed — an unhandled error that flakes the whole run even though +// every assertion passed. +if (typeof Range !== 'undefined') { + const emptyRectList = { + length: 0, + item: () => null, + *[Symbol.iterator] () {}, + } as unknown as DOMRectList; + const emptyRect = { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON() { + return {}; + }, + } as DOMRect; + if (!Range.prototype.getClientRects) { + Range.prototype.getClientRects = () => emptyRectList; + } + if (!Range.prototype.getBoundingClientRect) { + Range.prototype.getBoundingClientRect = () => emptyRect; + } +} + if (typeof navigator !== 'undefined' && !navigator.clipboard) { Object.defineProperty(navigator, 'clipboard', { configurable: true, From c13214e8e175bc772092acc671fcebec47c1ec43 Mon Sep 17 00:00:00 2001 From: tianyuan <2720711917@qq.com> Date: Sat, 11 Jul 2026 01:00:08 +0800 Subject: [PATCH 4/4] refactor(cli): use ToolNames constant and broaden permission tests Address review suggestions on #6655: - buildAllowConfirmationPayload now gates answers-promotion on the ToolNames.ASK_USER_QUESTION constant instead of a bare string literal, so a future rename of the tool name is a compile-time break rather than a silent regression. - Add an it.each case for a non-object primitive updatedInput (string) to cover the `typeof updatedInput !== 'object'` guard branch. - Assert the leader path overrides toolCall.request.args with the host's sanitized updatedInput before confirming. - Add a teammate-path test for an allow response with no updatedInput, asserting respond is called with (ProceedOnce, undefined). --- .../controllers/permissionController.test.ts | 71 ++++++++++++++----- .../controllers/permissionController.ts | 5 +- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts index bd1bb21e3b6..63de2c3cead 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.test.ts @@ -114,22 +114,21 @@ describe('PermissionController', () => { }, }); const onConfirm = vi.fn(); - - controller.getToolCallUpdateCallback()([ - { - status: 'awaiting_approval', - request: { - callId: 'tool-call-answers', - name: 'ask_user_question', - args: { questions: [] }, - }, - confirmationDetails: { - type: 'ask_user_question', - title: 'Please answer', - onConfirm, - }, + const toolCall = { + status: 'awaiting_approval', + request: { + callId: 'tool-call-answers', + name: 'ask_user_question', + args: { questions: [] } as Record, }, - ]); + confirmationDetails: { + type: 'ask_user_question', + title: 'Please answer', + onConfirm, + }, + }; + + controller.getToolCallUpdateCallback()([toolCall]); await vi.waitFor(() => { expect(onConfirm).toHaveBeenCalledWith( @@ -137,6 +136,10 @@ describe('PermissionController', () => { expect.objectContaining({ answers }), ); }); + + // The leader path overrides the tool's in-process args with the + // host's sanitized updatedInput before confirming. + expect(toolCall.request.args).toEqual({ questions: [], answers }); }); it('omits answers from the payload when updatedInput has none', async () => { @@ -229,6 +232,7 @@ describe('PermissionController', () => { it.each([ ['updatedInput is an array', ['ls'], undefined], + ['updatedInput is a string', 'ls', undefined], ['answers is an array', { questions: [], answers: ['x'] }, undefined], ['answers is null', { questions: [], answers: null }, undefined], ['answers is an empty object', { questions: [], answers: {} }, {}], @@ -270,8 +274,13 @@ describe('PermissionController', () => { const [outcome, payload] = onConfirm.mock.calls[0]; expect(outcome).toBe(ToolConfirmationOutcome.ProceedOnce); - if (Array.isArray(updatedInput)) { - // An array updatedInput is rejected wholesale — plain confirm. + const isPlainObject = + updatedInput !== null && + typeof updatedInput === 'object' && + !Array.isArray(updatedInput); + if (!isPlainObject) { + // A non-object updatedInput (array or primitive) is rejected + // wholesale — plain confirm, no payload. expect(payload).toBeUndefined(); } else if (expectedAnswers === undefined) { expect(payload).toEqual({ updatedInput }); @@ -435,6 +444,34 @@ describe('PermissionController', () => { ); }); + it('confirms a teammate approval with no payload when updatedInput is absent', async () => { + const context = createContext(120_000); + const controller = new PermissionController( + context, + createRegistry(), + 'PermissionController', + ); + vi.spyOn(controller, 'sendControlRequest').mockResolvedValue({ + subtype: 'success', + request_id: 'teammate-request-no-input', + response: { behavior: 'allow' }, + }); + const respond = vi.fn().mockResolvedValue(undefined); + + await controller.handleTeammateApproval({ + teammateName: 'worker', + toolName: 'run_shell_command', + toolInput: { command: 'ls' }, + respond, + timestamp: 789, + }); + + expect(respond).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + undefined, + ); + }); + it('omits modify suggestions when edit confirmation hides modify actions', () => { const controller = new PermissionController( createContext(), diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index f798fec47f1..a3179f2c8c4 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -25,6 +25,7 @@ import type { import { InputFormat, ToolConfirmationOutcome, + ToolNames, } from '@qwen-code/qwen-code-core'; import type { CLIControlPermissionRequest, @@ -415,7 +416,9 @@ export class PermissionController extends BaseController { } const updatedInputObj = updatedInput as Record; const answers = - toolName === 'ask_user_question' ? updatedInputObj['answers'] : undefined; + toolName === ToolNames.ASK_USER_QUESTION + ? updatedInputObj['answers'] + : undefined; return { updatedInput: updatedInputObj, ...(answers && typeof answers === 'object' && !Array.isArray(answers)