Files
MailUI4Agents/client/electron/test/components/PermissionPanel.test.tsx
JianFeeeee f91efd2d8d feat(question): DSH ask_user_question 桥接 + 前端问答面板 + 待办字段全路径透出
问题(P0):DSH 有两个独立的人机交互 seam —— approval/request(危险工具审批)
与 ask_user_question → ctx.userQuestions(模型主动提问)。原来只桥接了前者。
邮件驱动的会话没有本地 UI,而 ask() 的 provider 是 DSH host 注册的本地 UI 实现,
于是在那里等人点选永久等不到,那一轮工具调用**静默挂死**。

修法(不抢注全局 provider —— registerProvider 只允许一个活动实例,抢注会让
平台自己的界面失效):在 tools/execute around-dispatch 里只对**邮件驱动**的
会话接管 ask_user_question,其余原样 next()。失败一律当场报错而不是 next():
下一个 answerer 是本地 UI,邮件会话没有兜底 UI,放过去就是挂死。

- lib/user-question.js(三桥逐字节同源,14 例测试):DSH questions[] ↔ AgentMail
  单问题询问邮件的双向映射。多问题时把选项并集摊平、按 label 归属分配回各问题
  (label 认不出来就不猜测放行);无选项题走自由文本 custom。
- Gateway:kind=question 且无选项时**不再**回落「同意/拒绝」(那会让自由文本
  问题变成两个毫无意义的按钮);主题按类型区分「权限请求 / 需要回答」;
  推送 payload 带上 permission_kind / multi_select / options。
- mails.permission_kind / permission_multi_select 此前只存在于结构体与写入路径,
  五个读路径的 SELECT/Scan 都没带 —— 前端永远拿到空串,把提问渲染成批准/拒绝。
  container 修正五处并加 repo 测试(含反向验证:删掉任一处字段,测试即失败)。
- 前端 PermissionPanel:question 走「勾选 + 自由文本」,多选/单选、空回答禁止提交;
  approval 路径不变(回归测试覆盖)。

测试:opencode 316 / dsh 349 / pi 405 / 前端 185 / Go 全量 全绿。
2026-09-11 10:44:18 +08:00

327 lines
13 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();
});
});