Skip to content
Open
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
22 changes: 14 additions & 8 deletions web/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 || {}) };
Expand All @@ -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));
}
Expand Down Expand Up @@ -903,11 +903,17 @@ export function App() {
/>
{permReq && (
<PermissionModal
// key 强制按请求重挂载:队首 A→B 切换时若复用实例,Modal 内部
// show=false 的透明遮罩会挡住整页且 B 的弹窗不可见(A 刚经历
// 关闭动画),resolvedRef 也会残留上一条的已回应状态。
key={permReq.request_id}
request={permReq}
originLabel={permReq.session_id && subagentIndex.titles[permReq.session_id]
? `来自后台任务:${subagentIndex.titles[permReq.session_id]}`
: ''}
onResolve={() => setPermReqs((prev) => prev.slice(1))}
// 按 request_id 幂等移除。切勿盲删队首:关闭路径可能多次触发
// onResolve,窗口内到达的下一条请求会被误删(权限弹窗失踪 bug)。
onResolve={(requestId) => setPermReqs((prev) => removePermissionRequest(prev, requestId))}
/>
)}
</div>
Expand Down
10 changes: 7 additions & 3 deletions web/src/components/PermissionModal.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand Down
24 changes: 24 additions & 0 deletions web/src/lib/permissionRequestQueue.js
Original file line number Diff line number Diff line change
@@ -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;
}
65 changes: 65 additions & 0 deletions web/src/lib/permissionRequestQueue.test.js
Original file line number Diff line number Diff line change
@@ -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');
1 change: 1 addition & 0 deletions web/src/lib/runTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading