问题(⑧):守护进程用 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 按设计排除。
379 lines
13 KiB
TypeScript
379 lines
13 KiB
TypeScript
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 落定并等状态更新走完,
|
||
// 否则组件在测试结束后才 setState,React 会报 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();
|
||
});
|
||
});
|