diff --git a/web/src/App.jsx b/web/src/App.jsx index a310873b..95b87636 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -64,6 +64,10 @@ import { stripOpenSessionParams, } from './lib/sessionJump.js'; import { desktopUiMode } from './lib/desktopShellMode.js'; +import { + pushPermissionRequest, + removePermissionRequest, +} from './lib/permissionRequestQueue.js'; import { desktopGuidedTourHasModel, desktopGuidedTourModeEligible, @@ -437,12 +441,6 @@ export function App() { }, [resumeAndOpenSession]); useEffect(() => { - const pushUnique = (setter, payload) => { - if (!payload?.request_id) return; - setter((prev) => prev.some((x) => x.request_id === payload.request_id) - ? prev - : [...prev, payload]); - }; const handler = (e) => { const msg = e.detail || {}; const payload = { ...(msg.payload || {}) }; @@ -451,7 +449,9 @@ export function App() { const current = activeRefRef.current || {}; payload.session_id = current.sessionId || current.id || ''; } - if (msg.type === 'permission_request') pushUnique(setPermReqs, payload); + if (msg.type === 'permission_request') { + setPermReqs((prev) => pushPermissionRequest(prev, payload)); + } if (msg.type === 'question_request') { setQuestionReqs((prev) => addPendingQuestionRequest(prev, payload)); } @@ -903,11 +903,17 @@ export function App() { /> {permReq && ( setPermReqs((prev) => prev.slice(1))} + // 按 request_id 幂等移除。切勿盲删队首:关闭路径可能多次触发 + // onResolve,窗口内到达的下一条请求会被误删(权限弹窗失踪 bug)。 + onResolve={(requestId) => setPermReqs((prev) => removePermissionRequest(prev, requestId))} /> )} diff --git a/web/src/components/PermissionModal.jsx b/web/src/components/PermissionModal.jsx index 30aba99c..74cf4e87 100644 --- a/web/src/components/PermissionModal.jsx +++ b/web/src/components/PermissionModal.jsx @@ -1,5 +1,8 @@ // permission_request 弹框。Allow / AllowAlways / Deny 三按钮。 -// 多 request 排队由 App 层管(本组件每次只显示一个)。 +// 多 request 排队由 App 层管(本组件每次只显示一个,按 request_id key 重挂载)。 +// onResolve(request_id) 只经 Modal 的 onClose 单一路径触发,App 层按 id 幂等 +// 移除 —— 历史实现 respond 里额外 setTimeout(220ms) 再触发一次 + App 盲删 +// 队首,会把点击后窗口内到达的下一条请求误删(弹窗失踪、后端空等 5 分钟)。 import { useMemo, useRef } from 'react'; import { connection } from '../lib/connection.js'; @@ -73,8 +76,9 @@ export function PermissionModal({ request, onResolve, originLabel = '' }) { const respond = (choice, close) => { resolvedRef.current = true; connection.sendDecision(request.request_id, choice, request.session_id); + // 渐隐动画结束后 Modal 调 onClose(handleModalClose)完成移除; + // 这里不再自行 setTimeout 触发 onResolve,避免一次点击移除两条。 close(); - setTimeout(() => onResolve?.(), 220); }; // Escape key or backdrop click: send deny immediately so the backend is @@ -83,7 +87,7 @@ export function PermissionModal({ request, onResolve, originLabel = '' }) { if (!resolvedRef.current) { connection.sendDecision(request.request_id, 'deny', request.session_id); } - onResolve?.(); + onResolve?.(request.request_id); }; return ( diff --git a/web/src/lib/permissionRequestQueue.js b/web/src/lib/permissionRequestQueue.js new file mode 100644 index 00000000..5962f93b --- /dev/null +++ b/web/src/lib/permissionRequestQueue.js @@ -0,0 +1,24 @@ +// 权限请求队列(App.jsx 的 permReqs)的纯状态操作。 +// +// 背景:permission_request 经 WS 实时帧 + subscribe 补发(routes_ws.cpp 的 +// snapshot_pending_requests)两条通路到达,可能重复 → push 必须按 request_id +// 去重。移除侧曾是"盲删队首 slice(1)":PermissionModal 的 respond 里 +// setTimeout(220ms) 与 Modal close 动画(200ms 后 onClose)双重触发 onResolve, +// 一次点击删两条 —— 若"拒绝"后同一批次的下一个写工具在窗口内弹出新请求 +// (agent_loop Phase 2 串行写工具,deny 解除阻塞后毫秒级发出下一条),新请求 +// 被第二刀误删:弹窗永远不出现,后端 AsyncPrompter 空等 5 分钟超时。 +// 修复 = 移除按 request_id 幂等过滤,重复触发无害。 + +// 入队:按 request_id 去重;缺 request_id 的 payload 丢弃(无法路由决策)。 +export function pushPermissionRequest(list, payload) { + if (!payload?.request_id) return list; + if (list.some((x) => x.request_id === payload.request_id)) return list; + return [...list, payload]; +} + +// 幂等移除:未命中(含 requestId 为空)返回原引用,不触发 React 重渲染。 +export function removePermissionRequest(list, requestId) { + if (!requestId) return list; + const next = list.filter((x) => x.request_id !== requestId); + return next.length === list.length ? list : next; +} diff --git a/web/src/lib/permissionRequestQueue.test.js b/web/src/lib/permissionRequestQueue.test.js new file mode 100644 index 00000000..61b6da6a --- /dev/null +++ b/web/src/lib/permissionRequestQueue.test.js @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import { + pushPermissionRequest, + removePermissionRequest, +} from './permissionRequestQueue.js'; + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +const reqA = { request_id: 'rid-a', tool: 'file_write', args: { path: 'a.go' } }; +const reqB = { request_id: 'rid-b', tool: 'file_write', args: { path: 'b.go' } }; + +run('push 按 request_id 去重(实时帧与 subscribe 补发重复推送同一请求)', () => { + // 触发场景:WS 重连后 subscribe 补发 pending 请求,与之前收到的实时帧重复。 + // 期望:同 request_id 只保留一条,且返回原引用避免无谓重渲染。 + let list = []; + list = pushPermissionRequest(list, reqA); + const dup = pushPermissionRequest(list, { ...reqA }); + assert.equal(dup, list); + assert.equal(list.length, 1); +}); + +run('push 丢弃缺 request_id 的 payload(决策无法路由,不该进队列)', () => { + const list = []; + assert.equal(pushPermissionRequest(list, { tool: 'bash' }), list); + assert.equal(pushPermissionRequest(list, null), list); +}); + +run('remove 按 request_id 精确移除,不影响其它请求', () => { + let list = []; + list = pushPermissionRequest(list, reqA); + list = pushPermissionRequest(list, reqB); + list = removePermissionRequest(list, 'rid-a'); + assert.deepEqual(list.map((x) => x.request_id), ['rid-b']); +}); + +run('回归:重复移除同一 request_id 不误删后续请求', () => { + // Bug 表现:旧实现 onResolve 盲删队首(slice(1)),而 PermissionModal 的 + // close 动画路径(~200ms)与 respond 自身 setTimeout(220ms)会双重触发 + // onResolve。用户点"拒绝"后,agent_loop 串行执行同批次下一个写工具、 + // 毫秒级发来新请求 B 落进这 220ms 窗口 → 第二刀把 B 删掉,弹窗永远 + // 不出现,后端 AsyncPrompter 空等 5 分钟超时(UI 一直"正在等待权限确认")。 + // 期望:按 request_id 幂等移除,第二次触发是 no-op,B 存活并弹窗。 + let list = []; + list = pushPermissionRequest(list, reqA); + list = removePermissionRequest(list, 'rid-a'); // 第一次:close 动画 onClose 路径 + list = pushPermissionRequest(list, reqB); // 拒绝后下一条请求在窗口内到达 + list = removePermissionRequest(list, 'rid-a'); // 第二次:历史上的 setTimeout 路径 + assert.deepEqual(list.map((x) => x.request_id), ['rid-b']); +}); + +run('remove 未命中/空 id 返回原引用(no-op 不触发重渲染)', () => { + const list = [reqA]; + assert.equal(removePermissionRequest(list, 'rid-unknown'), list); + assert.equal(removePermissionRequest(list, ''), list); +}); + +console.log('permissionRequestQueue tests passed'); diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 75030545..dd99f68d 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -35,6 +35,7 @@ import './sessionModel.test.js'; import './compactMessagePreview.test.js'; import './permissionMode.test.js'; import './permissionRequestPresentation.test.js'; +import './permissionRequestQueue.test.js'; import './tokenBudget.test.js'; import './usageStats.test.js'; import './desktopFeedback.test.js';