Răsfoiți Sursa

fix(subagent): keep Claude plan mode non-executing

pku-xht 1 lună în urmă
părinte
comite
a3deb9aa5e

+ 2 - 2
.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md
-2026-08-15-product-subagent-noninteractive-permissions.md: d4d29d982e5eb2a06f7cb710860ce72c506c4ade
-2026-08-15-product-subagent-noninteractive-permissions.zh.md: 3431465e6240e169dd8d240628d651348ac029b7
+2026-08-15-product-subagent-noninteractive-permissions.md: 9ab4887dda61895161392e7ff3aee164e765ee26
+2026-08-15-product-subagent-noninteractive-permissions.zh.md: f6d7b438fb0e9b501640be96c298f8690d1313d3

+ 1 - 1
.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md

@@ -24,7 +24,7 @@ The Claude Code Provider owns one Profile-level `permissionMode` value. It defau
 
 The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode.
 
-Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; in plan mode, `ExitPlanMode` receives a fixed denial that tells the model to return the completed plan without executing it. MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop.
+Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; plan mode also places `ExitPlanMode` in `disallowedTools`, so native allow rules cannot switch the unattended query back to execution. MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop.
 
 ### Failure diagnostic
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md

@@ -24,7 +24,7 @@ Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认
 
 提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。
 
-每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;在 plan 模式下,`ExitPlanMode` 会收到一项固定拒绝,要求模型返回完整计划且不得执行。MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。
+每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;plan 模式还会把 `ExitPlanMode` 放入 `disallowedTools`,因此原生 allow 规则无法把无人值守 query 切回执行模式。MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。
 
 ### 失败诊断
 

+ 2 - 2
packages/subagent/subagent-claude-code/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subagent/subagent-claude-code/README.md
-README.md: e7c5debddfdc740802d7bc25c2a863c7de287d07
-README.zh.md: 9e68b5f3f3824ffc3913fdba159c95b5c94353c9
+README.md: be3b2262addc487e545fed1f792600a9a5ca24c0
+README.zh.md: 7ea1b8ca7243790afd387b04d776088cea012718

+ 1 - 1
packages/subagent/subagent-claude-code/README.md

@@ -16,7 +16,7 @@ Local cancellation wins the result race and maps to `aborted`. `dispose()` is id
 
 The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks.
 
-Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. In plan mode, the `ExitPlanMode` approval is denied with a fixed instruction to return the completed plan as the final answer without executing it. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail.
+Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail.
 
 ## Capabilities and context
 

+ 1 - 1
packages/subagent/subagent-claude-code/README.zh.md

@@ -16,7 +16,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 
 
 提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。
 
-每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。在 plan 模式下,`ExitPlanMode` 审批会被拒绝,同时用固定指令要求模型把完整计划作为最终答案返回且不得执行。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。
+每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。
 
 ## 能力与上下文
 

+ 5 - 15
packages/subagent/subagent-claude-code/src/run.ts

@@ -59,7 +59,7 @@ const SUPPORTED_UNATTENDED_DIALOG_KINDS = [
 
 function unattendedDiagnostic(
   mode: ClaudeCodePermissionMode,
-  request: 'tool permission' | 'plan approval' | 'MCP elicitation' | 'user dialog',
+  request: 'tool permission' | 'MCP elicitation' | 'user dialog',
   decision: 'denied' | 'declined' | 'cancelled',
   reason: string,
 ): string {
@@ -223,24 +223,14 @@ export function claudeQueryOptions(
     pathToClaudeCodeExecutable: spec.executable,
     env: { ...scrubbedParentEnv(), ...spec.env },
     persistSession: false,
-    disallowedTools: ['AskUserQuestion'],
+    disallowedTools: spec.permissionMode === 'plan'
+      ? ['AskUserQuestion', 'ExitPlanMode']
+      : ['AskUserQuestion'],
     permissionMode: spec.permissionMode,
     ...spec.permissionMode === 'bypassPermissions'
       ? { allowDangerouslySkipPermissions: true }
       : {
-        canUseTool: (toolName) => {
-          if (spec.permissionMode === 'plan' && toolName === 'ExitPlanMode') {
-            captureDiagnostic(unattendedDiagnostic(
-              spec.permissionMode,
-              'plan approval',
-              'denied',
-              'the provider returns the plan without approving execution',
-            ))
-            return Promise.resolve({
-              behavior: 'deny' as const,
-              message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.',
-            })
-          }
+        canUseTool: () => {
           captureDiagnostic(unattendedDiagnostic(
             spec.permissionMode,
             'tool permission',

+ 8 - 2
packages/subagent/subagent-claude-code/tests/real-product.spec.ts

@@ -127,6 +127,7 @@ interface RealHarness {
 async function realHarness(
   behavior: MessagesBehavior,
   permissionMode?: ClaudeCodePermissionMode,
+  nativeAllow: readonly string[] = [],
 ): Promise<{
   readonly harness: RealHarness
   readonly fixture: MessagesFixture
@@ -151,7 +152,10 @@ async function realHarness(
     join(claudeConfig, 'settings.json'),
     `${JSON.stringify({
       model: settingsModel,
-      permissions: { defaultMode: 'default' },
+      permissions: {
+        defaultMode: 'default',
+        ...nativeAllow.length === 0 ? {} : { allow: nativeAllow },
+      },
     }, null, 2)}\n`,
   )
   const fixture = await startMessagesFixture(behavior)
@@ -367,13 +371,15 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220
       toolName: 'ExitPlanMode',
       input: {},
       finalText: 'PLAN_ONLY_RESULT',
-    }, 'plan')
+    }, 'plan', ['ExitPlanMode'])
     const run = await startRequest(harness, 'Design the fixture change without implementing it.')
     await expect(run.result).resolves.toEqual({
       output: [{ type: 'text', text: 'PLAN_ONLY_RESULT' }],
       stopReason: 'completed',
     })
     expect(fixture.requests).toHaveLength(2)
+    expect(JSON.stringify(fixture.requests[1]?.body.messages))
+      .toContain('ExitPlanMode exists but is not enabled in this context')
     await run.dispose()
     await expectQuiescent(harness.handles)
   })

+ 7 - 16
packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts

@@ -679,6 +679,9 @@ describe('query options and result mapping', () => {
         spawn: () => child.handle,
       }, new AbortController(), () => {}, () => {})
       expect(options.permissionMode).toBe(permissionMode)
+      expect(options.disallowedTools).toEqual(permissionMode === 'plan'
+        ? ['AskUserQuestion', 'ExitPlanMode']
+        : ['AskUserQuestion'])
       if (permissionMode === 'bypassPermissions') {
         expect(options.allowDangerouslySkipPermissions).toBe(true)
         expect(options).not.toHaveProperty('canUseTool')
@@ -689,9 +692,8 @@ describe('query options and result mapping', () => {
     },
   )
 
-  it('returns a plan without approving ExitPlanMode execution', async () => {
+  it('disallows ExitPlanMode before native plan-mode allow rules', () => {
     const child = fakeChild()
-    const diagnostics: string[] = []
     const options = claudeQueryOptions({
       cwd: '/workspace',
       executable: '/native/claude',
@@ -699,21 +701,10 @@ describe('query options and result mapping', () => {
       env: {},
       disposeGraceMs: 17,
       spawn: () => child.handle,
-    }, new AbortController(), () => {}, value => diagnostics.push(value))
-    await expect(options.canUseTool!(
+    }, new AbortController(), () => {}, () => {})
+    expect(options.disallowedTools).toEqual([
+      'AskUserQuestion',
       'ExitPlanMode',
-      {},
-      {
-        signal: new AbortController().signal,
-        toolUseID: 'exit-plan',
-        requestId: 'exit-plan-request',
-      },
-    )).resolves.toEqual({
-      behavior: 'deny',
-      message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.',
-    })
-    expect(diagnostics).toEqual([
-      'Claude Code unattended decision (mode: plan; request: plan approval; decision: denied): the provider returns the plan without approving execution',
     ])
   })