Files
MailUI4Agents/client/electron/test/components/PermissionPanel.test.tsx
JianFeeeee bbddee26b9 feat(permission): 待办带上失效时刻;越窗的决策不再假装成功
# 起因:一次端到端验证暴露的静默缺口

建了示例工程让 pi 通过邮件干活(plan 档拦截、workspace 档审批、多 agent 指派)。
plan 档与多 agent 都通过,workspace 档却卡住:**人在界面上批准了一条待办,
接口回 200,但那件事什么都没发生。**

追下去是三件事叠在一起:

1. **桥**等不到决策时(pi 的回合超时 TURN_TIMEOUT_MS,默认 10 分钟)会拆掉 worker
   与它的决策路由表;此后再来的决策只会作为**通知**投给 Agent,不恢复当时那次
   工具调用 —— 该轮已经结束了。
2. **服务端**只有 `permission_requests.result IS NULL`,没有「失效」概念。
   迟到决策照样回 `{"status":"decided"}`。
3. **前端**只看 `permission_result` 判待决/已决,没有任何时间或失效提示。

于是那条待办永远挂在授权页上显示「等待你决策」,人点了也白点。这是 I-5
(失败必须当场可见)要消灭的那类静默成功,而且**跨所有客户端**成立 ——
WebUI 不显示,Electron / Harmony 同样无从显示。

# 设计:邮件上给「时刻」,不给「是否失效」的布尔值

服务端不知道插件此刻是否还在等(那是它进程内的状态),所以只标出「这封待办已经
放了很久」,不替插件宣布裁决。

关键取舍:对外只发**截止时刻**(`permission_expires_at`),不发 `stale` 布尔值。
布尔值是「发出那一刻」的快照 —— 经 SSE 推送并被客户端缓存后会永久停在旧值,
界面就会一直显示「等待你决策」。时刻是持久事实,任何客户端在任何时候都能自己
比出现在过没过期。这也是为什么推导而非落库:它是 created_at 的函数,存下来会失真。

`DecidePermission` 的响应里则用布尔值(`expired`)—— 响应本身就是「此刻」的
一次性快照,不会像邮件那样被缓存反复展示。

# 改动

- `models.PermissionWaitWindow`(10 分钟,与 pi 桥的回合超时同量级)+
  `PermissionDeadline(createdAt)`;两端共用这一处算式,避免「界面说已过期、
  决策说没过期」。
- `Mail.PermissionExpiresAt` / `PermissionRequest.ExpiresAt`:由读路径推导填充。
  5 个读路径各插一行(`AttachPermissionDeadline*`)—— 与审计修复① 加
  permission_kind 时同一套路数,漏掉任一路径只会静默变成 nil。
  只给**仍未决策**的待办填,已决策的不再是待办。
- `decideResponse`(抽出纯函数以便测试):越窗时加 `expired` + `warning`,
  讲清「决策已记录、但不会恢复原调用」。**不改 HTTP 状态码**:决策仍是人的真实
  意愿、仍然有效(桥会当通知投递,Agent 重起一轮),所以不能拒掉,但必须说清。
- 前端:列表里失效项不再与「还能立刻生效」的长得一样(灰底 + 「可能已失效」);
  批准面板在决策**前**(人正要按下去)与决策**后**(人以为事情办了)都显示提示。

# 验证

- Go:models/repo/handler 三处新增测试全绿;全量 `go test ./...` 通过;vet 通过
- 前端:typecheck 通过;200 项测试全绿(含新增 4 条失效态)
- 真机(用现成的过期待办,未造合成数据):
  - `/permission/pending` 返回 `expires_at` = 创建 + 10 分钟,服务端判定已过窗
  - 邮件载荷带上 `permission_expires_at`(前端列表的数据源)
  - 对过期待办提交批准 → `{"expired":true, "expires_at":…, "warning":"该请求已超过
    等待窗口(10 分钟)…不会恢复当时那次工具调用…"}`
- 已用 redeploy-gateway.sh 部署,服务 active、四 agent 心跳正常、日志无 panic
2026-09-11 22:02:25 +08:00

389 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { PermissionPanel } from '../../src/components/MailView';
import * as api from '../../src/api/client';
import { useMailStore } from '../../src/stores/mailStore';
import { useSessionStore } from '../../src/stores/sessionStore';
import type { Mail } from '../../src/types';
/**
* 权限决策面板。
*
* 这是全站唯一一处「人的一次点击直接放行 Agent 的危险操作」,
* 因此断言集中在两件事:
* - 点下去到底把什么发给了服务端(选项原文,不是归一化后的 allow/deny
* - 已决策的请求不能再点第二次
*/
function permMail(over: Partial<Mail> = {}): Mail {
return {
mail_id: 'm-1',
session_id: 's-1',
parent_mail_id: null,
from_name: 'dsh',
from_workspace: '/home/program/agentmail',
to_name: 'admin',
to_workspace: '',
cc_list: [],
subject: '请求批准:删除 build/',
body: '将执行 rm -rf build/',
mail_type: 'permission_request',
permission_options: undefined,
permission_result: '',
status: 'unread',
created_at: '2026-09-03T00:00:00Z',
hop_limit: 5,
...over
} as Mail;
}
describe('PermissionPanel 决策', () => {
beforeEach(() => {
vi.restoreAllMocks();
// fetchInbox / selectSession 会打网络,替换成空实现
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
});
it('没有 permission_options 时给默认的同意/拒绝两个选项', () => {
render(React.createElement(PermissionPanel, { mail: permMail() }));
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument();
});
it('有 permission_options 时用它,且顺序保持', () => {
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_options: ['只这一次', '总是允许', '拒绝'] })
})
);
const btns = screen.getAllByRole('button').map(b => b.textContent?.trim());
// 顺序是 Agent 给的语义顺序,重排会让「拒绝」跑到人的手指默认位置上
expect(btns).toEqual(['只这一次', '总是允许', '拒绝']);
});
it('点选项时把【选项原文】发给服务端', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_options: ['只这一次', '拒绝'] })
})
);
await userEvent.click(screen.getByRole('button', { name: /只这一次/ }));
// 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别
// 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译
await waitFor(() =>
expect(spy).toHaveBeenCalledWith('m-1', '只这一次', undefined)
);
});
it('填了备注时一起发出去', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.type(screen.getByPlaceholderText('备注(可选)'), '只删 build别动 dist');
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
await waitFor(() =>
expect(spy).toHaveBeenCalledWith('m-1', '同意', '只删 build别动 dist')
);
});
it('备注为空时传 undefined 而不是空字符串', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
// 空串会在决策邮件里留一行空的「备注:」
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', '同意', undefined));
});
it('决策后变成「已处理」,不再显示按钮', async () => {
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
expect(screen.queryByRole('button')).toBeNull();
});
it('已经有 permission_result 的邮件直接显示结论', () => {
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_result: '拒绝' })
})
);
expect(screen.getByText('拒绝')).toBeInTheDocument();
expect(screen.queryByRole('button')).toBeNull();
});
it('提交中禁用所有按钮,避免重复决策', async () => {
let release: (v: any) => void = () => {};
vi.spyOn(api, 'decidePermission').mockReturnValue(
new Promise(res => {
release = res;
}) as any
);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
// 一次危险操作被批准两次Agent 那边可能真的执行两遍
await waitFor(() => {
for (const b of screen.getAllByRole('button')) {
expect(b).toBeDisabled();
}
});
// 收尾:让悬挂的 Promise 落定并等状态更新走完,
// 否则组件在测试结束后才 setStateReact 会报 act 警告
await act(async () => {
release({ status: 'decided' });
});
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
});
it('提交失败时恢复可点,不假装已决策', async () => {
vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500'));
vi.spyOn(console, 'error').mockImplementation(() => {});
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
// 失败后显示「已处理」是最糟的结果人以为批过了Agent 还在等
await waitFor(() => expect(screen.getByRole('button', { name: /同意/ })).toBeEnabled());
expect(screen.queryByText('已处理:')).toBeNull();
});
it('决策成功后刷新收件箱并选中该会话', async () => {
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
const fetchInbox = vi.fn(async () => {});
const selectSession = vi.fn(async () => {});
useMailStore.setState({ fetchInbox } as any);
useSessionStore.setState({ selectSession } as any);
render(React.createElement(PermissionPanel, { mail: permMail() }));
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
await waitFor(() => {
expect(fetchInbox).toHaveBeenCalledWith('all');
expect(selectSession).toHaveBeenCalledWith('s-1');
});
});
it('同意类选项用绿色,其余用红色', () => {
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_options: ['允许', 'approve', '拒绝', '算了'] })
})
);
const cls = (name: string) =>
screen.getByRole('button', { name: new RegExp(name) }).className;
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
expect(cls('允许')).toContain('bg-green-700');
expect(cls('approve')).toContain('bg-green-700');
expect(cls('拒绝')).toContain('text-red-700');
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
expect(cls('算了')).toContain('text-red-700');
});
});
/**
* 模型主动提问permission_kind='question')。
*
* 与审批型共用 permission_request 这个 mail_type但语义完全不同
* 这里是「回答问题」而不是「批准执行」。断言集中在两件事:
* - 不能把问题渲染成同意/拒绝(那会让人点出一个毫无意义的答案)
* - 空回答不能提交(模型会拿到一个什么都没说的结果继续跑)
*/
describe('PermissionPanel 回答问题', () => {
beforeEach(() => {
vi.restoreAllMocks();
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
});
const questionMail = (over: Partial<Mail> = {}): Mail =>
permMail({ permission_kind: 'question', ...over });
it('问题不带选项时:不渲染同意/拒绝,只给自由文本', () => {
render(React.createElement(PermissionPanel, { mail: questionMail({ permission_options: [] }) }));
expect(screen.queryByRole('button', { name: /同意/ })).toBeNull();
expect(screen.queryByRole('button', { name: /拒绝/ })).toBeNull();
expect(screen.getByPlaceholderText('你的回答(必填)')).toBeInTheDocument();
});
it('问题带选项时:渲染选项按钮(而不是同意/拒绝)', () => {
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_options: ['方案 A', '方案 B'] })
})
);
expect(screen.getByRole('button', { name: /方案 A/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /方案 B/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^同意$/ })).toBeNull();
});
it('单选:再点已选项会取消,不会同时选中两个', async () => {
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_options: ['A', 'B'] })
})
);
const a = screen.getByRole('button', { name: /^A$/ });
const b = screen.getByRole('button', { name: /^B$/ });
await userEvent.click(a);
expect(a).toHaveAttribute('aria-pressed', 'true');
await userEvent.click(b);
expect(b).toHaveAttribute('aria-pressed', 'true');
expect(a).toHaveAttribute('aria-pressed', 'false');
});
it('多选:可同时选中多项,提交时用换行拼接', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_options: ['A', 'B'], permission_multi_select: true })
})
);
await userEvent.click(screen.getByRole('button', { name: /^A$/ }));
await userEvent.click(screen.getByRole('button', { name: /^B$/ }));
await userEvent.click(screen.getByRole('button', { name: /提交回答/ }));
// 服务端按换行拆分多选答案,不能拼接成 "AB" 或数组字符串
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', 'A\nB', undefined));
});
it('空回答禁止提交(模型不能拿到一个什么都没说的结果)', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(React.createElement(PermissionPanel, { mail: questionMail({ permission_options: [] }) }));
const submit = screen.getByRole('button', { name: /提交回答/ });
expect(submit).toBeDisabled();
expect(screen.getByText('请先选择或填写回答')).toBeInTheDocument();
await userEvent.type(screen.getByPlaceholderText('你的回答(必填)'), '配置在 /etc/foo.conf');
expect(submit).toBeEnabled();
await userEvent.click(submit);
await waitFor(() =>
expect(spy).toHaveBeenCalledWith('m-1', '', '配置在 /etc/foo.conf')
);
});
it('选了选项又写了备注:两者都发出去', async () => {
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_options: ['方案 A'] })
})
);
await userEvent.click(screen.getByRole('button', { name: /方案 A/ }));
await userEvent.type(screen.getByPlaceholderText('补充说明(可选)'), '但要先备份');
await userEvent.click(screen.getByRole('button', { name: /提交回答/ }));
await waitFor(() =>
expect(spy).toHaveBeenCalledWith('m-1', '方案 A', '但要先备份')
);
});
it('问题已回答过:显示结论,不再显示任何输入控件', () => {
render(
React.createElement(PermissionPanel, {
mail: questionMail({ permission_result: '方案 A' })
})
);
expect(screen.getByText(/已处理/)).toBeInTheDocument();
expect(screen.queryByRole('button')).toBeNull();
});
it('审批型(无 permission_kind仍然走同意/拒绝路径', () => {
render(React.createElement(PermissionPanel, { mail: permMail() }));
// 回归防护question 分支不能把普通审批也带走
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /提交回答/ })).toBeNull();
});
});
/**
* 越窗的待办必须可见。
*
* 实测2026-09-11pi 桥等不到决策时会在回合超时(默认 10 分钟)拆掉 worker
* 与决策路由表,此后的批准只会作为通知投递给 Agent。而界面当时照样显示「等待你
* 决策」、点下去也照样回「已处理」—— 人以为事情办了,实际什么都没发生。
*
* 判据分两处:决策**前**要能提前看见(人正要按下去),决策**后**要能知道这次
* 批准没恢复原调用(服务端回 warning
*/
describe('PermissionPanel 越窗(可能已失效)', () => {
beforeEach(() => {
vi.restoreAllMocks();
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
});
it('失效时刻已过:决策前就提示「不会恢复原调用」', () => {
const past = new Date(Date.now() - 60_000).toISOString();
render(React.createElement(PermissionPanel, { mail: permMail({ permission_expires_at: past }) }));
// 仍可决策(决策本身有效,桥会当通知投递),但必须讲清会发生什么
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
expect(screen.getByText(/不会恢复当时那次工具调用/)).toBeInTheDocument();
});
it('失效时刻未到:不提示,避免把还在等的待办说成过期', () => {
const future = new Date(Date.now() + 60 * 60_000).toISOString();
render(React.createElement(PermissionPanel, { mail: permMail({ permission_expires_at: future }) }));
expect(screen.queryByText(/不会恢复当时那次工具调用/)).toBeNull();
});
it('没有失效时刻(旧数据):不提示', () => {
render(React.createElement(PermissionPanel, { mail: permMail() }));
expect(screen.queryByText(/不会恢复当时那次工具调用/)).toBeNull();
});
it('决策后服务端回 warning把它显示在「已处理」旁边', async () => {
const warning = '该请求已超过等待窗口10 分钟),发起它的 Agent 很可能已不再阻塞等待。';
vi.spyOn(api, 'decidePermission').mockResolvedValue({
status: 'decided',
decision_mail_id: 'd-1',
expired: true,
warning
} as any);
render(
React.createElement(PermissionPanel, {
mail: permMail({ permission_options: ['同意', '拒绝'] })
})
);
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
await waitFor(() => {
expect(screen.getByText(/已处理/)).toBeInTheDocument();
});
// 关键:不能只显示「已处理:同意」就完事 —— 那正是静默成功
expect(screen.getByText(warning)).toBeInTheDocument();
});
});