Files
MailUI4Agents/client/electron/test/components/PermissionPanel.test.tsx
JianFeeeee 19a3161ee4 feat(pi): 交互式 pi 会话接入邮件工具(send_mail/read_inbox 等 10 个)
问题(⑧):守护进程用 noExtensions:true 起会话,它的邮件工具只给模型在邮件
会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己收不到邮件 ——
一个「邮件驱动」的平台,维护者只能绕到 curl + 密钥直连 Gateway 才能看收件箱。

新增 plugins/pi-mail-bridge/extension/index.ts:把同一套工具(createMailTools)
注册到交互式会话。两者是同一条 AgentMail 身份(agent pi)的两个入口,与 DSH 的
「TUI + 邮箱是同一个 Agent」一致。

密钥解析顺序(交互式 pi 的环境里没有 AGENTMAIL_*):
  1. 进程环境
  2. AGENTMAIL_ENV_FILE(默认 /etc/agentmail/pi.env)—— 与守护进程同一把密钥,
     因此身份一致
  3. AGENTMAIL_CONFIG_DIR/agent.key 或 ~/.agentmail/agent.key
     (兼容 key 与 key_token 两种字段名;实测本机文件用的是 key_token,
      只认 key 会静默读不到)
拿不到密钥时不注册任何工具并明确告知 —— 挂一组永远 401 的工具比没有更糟。

不注册 connect_to_server:它会重写 Gateway 坐标并重新登记密钥,而交互式会话与
守护进程共用同一身份,一次 TUI 对话不该改到守护进程的配置。

为什么不会重复注册(读 SDK 实现确认,并用探针实测):
  resource-loader.js 里 noExtensions 为真时只用 cliEnabledExtensions,
  settings.json 的 extensions 数组被排除 —— 即 noExtensions:true 只加载
  命令行 -e 传入的扩展。
  探针:noExtensions=true → 扩展数=0;false → 16 个且含 pi-mail-bridge。

deploy/install.sh 增加幂等的扩展注册步骤(写入 settings.json 的 extensions)。

验证:headless pi 实际调用 read_inbox 返回真实邮件主题;工具清单含
send_mail/read_inbox/read_mail/forward_mail/upload_attachment/download_attachment/
suggest_address/list_contacts/session_participants/read_thread(10 个),
connect_to_server 按设计排除。
2026-09-11 11:32:47 +08:00

379 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();
});
});