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 按设计排除。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -1,139 +1,139 @@
|
||||
export interface User {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: 'admin' | 'user';
|
||||
status: 'active' | 'disabled';
|
||||
allowed_agents: string[];
|
||||
allowed_paths: string[];
|
||||
last_login?: string;
|
||||
created_at?: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: "admin" | "user";
|
||||
status: "active" | "disabled";
|
||||
allowed_agents: string[];
|
||||
allowed_paths: string[];
|
||||
last_login?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
name: string;
|
||||
path: string;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
agent_id?: string;
|
||||
agent_name: string;
|
||||
workspaces: Workspace[];
|
||||
platform: string;
|
||||
status: string;
|
||||
/** 派给该 Agent 的新任务默认多少个来回(0 = 不限) */
|
||||
default_rounds?: number;
|
||||
agent_id?: string;
|
||||
agent_name: string;
|
||||
workspaces: Workspace[];
|
||||
platform: string;
|
||||
status: string;
|
||||
/** 派给该 Agent 的新任务默认多少个来回(0 = 不限) */
|
||||
default_rounds?: number;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
name: string;
|
||||
path: string;
|
||||
session: string;
|
||||
raw: string;
|
||||
name: string;
|
||||
path: string;
|
||||
session: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count?: number;
|
||||
/**
|
||||
* 别名是谁定的:
|
||||
* platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||
*/
|
||||
alias_source?: 'platform' | 'manual';
|
||||
/** 用户驳回过的改名提议 */
|
||||
rename_dismissed?: string;
|
||||
/**
|
||||
* 本任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
*
|
||||
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
|
||||
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
|
||||
*/
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
/** 权限档位:plan / workspace / full */
|
||||
permission_mode?: string;
|
||||
/** 档位实际强制力:native / advisory */
|
||||
permission_enforcement?: string;
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count?: number;
|
||||
/**
|
||||
* 别名是谁定的:
|
||||
* platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||
*/
|
||||
alias_source?: "platform" | "manual";
|
||||
/** 用户驳回过的改名提议 */
|
||||
rename_dismissed?: string;
|
||||
/**
|
||||
* 本任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
*
|
||||
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
|
||||
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
|
||||
*/
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
/** 权限档位:plan / workspace / full */
|
||||
permission_mode?: string;
|
||||
/** 档位实际强制力:native / advisory */
|
||||
permission_enforcement?: string;
|
||||
}
|
||||
|
||||
/** 会话往返预算快照 */
|
||||
export interface SessionBudget {
|
||||
session_id: string;
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
/** 不限时为 -1 */
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
session_id: string;
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
/** 不限时为 -1 */
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
/** 附件元数据。内容存盘,按 sha256 内容寻址;同内容重复上传不占额外空间。 */
|
||||
export interface Attachment {
|
||||
attachment_id: string;
|
||||
/** 为 null 表示已上传但尚未随邮件发出 */
|
||||
mail_id: string | null;
|
||||
uploader: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
sha256: string;
|
||||
created_at: string;
|
||||
attachment_id: string;
|
||||
/** 为 null 表示已上传但尚未随邮件发出 */
|
||||
mail_id: string | null;
|
||||
uploader: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
sha256: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Mail {
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
parent_mail_id: string | null;
|
||||
from_name: string;
|
||||
from_workspace: string;
|
||||
to_name: string;
|
||||
to_workspace: string;
|
||||
cc_list: Address[];
|
||||
subject: string;
|
||||
body: string;
|
||||
mail_type: 'normal' | 'permission_request';
|
||||
permission_options: string[] | null;
|
||||
permission_result: string | null;
|
||||
/**
|
||||
* 待办类型:`permission`(危险操作审批)/ `question`(模型主动提问)。
|
||||
*
|
||||
* 两者共用 permission_request 这个 mail_type,但**该渲染什么完全不同**:
|
||||
* 前者是「批准 / 拒绝」,后者是「回答问题」(勾选 + 自由文本)。
|
||||
* 混用一套 UI 会让人把「回答问题」当成「批准执行」。
|
||||
*/
|
||||
permission_kind?: string;
|
||||
/** 仅 question 使用:是否允许多选(对应 DSH 的 multi_select)。 */
|
||||
permission_multi_select?: boolean;
|
||||
status: 'unread' | 'read' | 'archived';
|
||||
created_at: string;
|
||||
hop_limit?: number;
|
||||
session_alias?: string;
|
||||
/**
|
||||
* **这条会话**的工作目录(sessions.workspace)。
|
||||
*
|
||||
* 不能用 from_workspace / to_workspace 代替:
|
||||
* - 人 → Agent:to_workspace 是真路径,from_workspace 为空(人没有工作目录)
|
||||
* - Agent → 人:to_workspace 为空,而 **from_workspace 存的是 Agent 名**
|
||||
* 而不是路径(历史遗留)
|
||||
*
|
||||
* 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取。
|
||||
* 界面上曾显示成 `dsh@dsh`,就是拿 from_workspace 当路径拼的。
|
||||
*/
|
||||
session_workspace?: string;
|
||||
body_preview?: string;
|
||||
attachments?: Attachment[];
|
||||
/** 发件方是人类用户而不是 Agent(服务端 EXISTS users 判的) */
|
||||
from_human: boolean;
|
||||
/** 收件方是人类用户而不是 Agent */
|
||||
to_human: boolean;
|
||||
permission_mode?: string;
|
||||
permission_enforcement?: string;
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
parent_mail_id: string | null;
|
||||
from_name: string;
|
||||
from_workspace: string;
|
||||
to_name: string;
|
||||
to_workspace: string;
|
||||
cc_list: Address[];
|
||||
subject: string;
|
||||
body: string;
|
||||
mail_type: "normal" | "permission_request";
|
||||
permission_options: string[] | null;
|
||||
permission_result: string | null;
|
||||
/**
|
||||
* 待办类型:`permission`(危险操作审批)/ `question`(模型主动提问)。
|
||||
*
|
||||
* 两者共用 permission_request 这个 mail_type,但**该渲染什么完全不同**:
|
||||
* 前者是「批准 / 拒绝」,后者是「回答问题」(勾选 + 自由文本)。
|
||||
* 混用一套 UI 会让人把「回答问题」当成「批准执行」。
|
||||
*/
|
||||
permission_kind?: string;
|
||||
/** 仅 question 使用:是否允许多选(对应 DSH 的 multi_select)。 */
|
||||
permission_multi_select?: boolean;
|
||||
status: "unread" | "read" | "archived";
|
||||
created_at: string;
|
||||
hop_limit?: number;
|
||||
session_alias?: string;
|
||||
/**
|
||||
* **这条会话**的工作目录(sessions.workspace)。
|
||||
*
|
||||
* 不能用 from_workspace / to_workspace 代替:
|
||||
* - 人 → Agent:to_workspace 是真路径,from_workspace 为空(人没有工作目录)
|
||||
* - Agent → 人:to_workspace 为空,而 **from_workspace 存的是 Agent 名**
|
||||
* 而不是路径(历史遗留)
|
||||
*
|
||||
* 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取。
|
||||
* 界面上曾显示成 `dsh@dsh`,就是拿 from_workspace 当路径拼的。
|
||||
*/
|
||||
session_workspace?: string;
|
||||
body_preview?: string;
|
||||
attachments?: Attachment[];
|
||||
/** 发件方是人类用户而不是 Agent(服务端 EXISTS users 判的) */
|
||||
from_human: boolean;
|
||||
/** 收件方是人类用户而不是 Agent */
|
||||
to_human: boolean;
|
||||
permission_mode?: string;
|
||||
permission_enforcement?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -145,30 +145,30 @@ export interface Mail {
|
||||
* depth 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
|
||||
* 分块加载时根可能还没取到,所以不用「距根深度」。
|
||||
*/
|
||||
export interface ThreadNode extends Omit<Mail, 'body'> {
|
||||
/** 距**线索根**的层级:0 = 根,1 = 它的直接回复 */
|
||||
depth: number;
|
||||
attachment_count: number;
|
||||
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
|
||||
detached?: boolean;
|
||||
/** 父邮件确实存在但无权查看(区别于「尚未加载」) */
|
||||
parent_hidden?: boolean;
|
||||
body?: string;
|
||||
export interface ThreadNode extends Omit<Mail, "body"> {
|
||||
/** 距**线索根**的层级:0 = 根,1 = 它的直接回复 */
|
||||
depth: number;
|
||||
attachment_count: number;
|
||||
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
|
||||
detached?: boolean;
|
||||
/** 父邮件确实存在但无权查看(区别于「尚未加载」) */
|
||||
parent_hidden?: boolean;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface ThreadPage {
|
||||
anchor_mail_id: string;
|
||||
/** 线索根的 mail_id:整棵树从它展开 */
|
||||
root_mail_id: string;
|
||||
/** 锚点距根的层数,用于高亮定位 */
|
||||
anchor_depth: number;
|
||||
nodes: ThreadNode[];
|
||||
total: number;
|
||||
/** 因权限被过滤掉的节点数 */
|
||||
hidden: number;
|
||||
has_more: boolean;
|
||||
/** 下一页 offset,原样回传即可 */
|
||||
next_offset: number;
|
||||
anchor_mail_id: string;
|
||||
/** 线索根的 mail_id:整棵树从它展开 */
|
||||
root_mail_id: string;
|
||||
/** 锚点距根的层数,用于高亮定位 */
|
||||
anchor_depth: number;
|
||||
nodes: ThreadNode[];
|
||||
total: number;
|
||||
/** 因权限被过滤掉的节点数 */
|
||||
hidden: number;
|
||||
has_more: boolean;
|
||||
/** 下一页 offset,原样回传即可 */
|
||||
next_offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -179,69 +179,69 @@ export interface ThreadPage {
|
||||
* 提议 + 人点头,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
*/
|
||||
export interface RenameProposal {
|
||||
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
|
||||
alias: string;
|
||||
reason?: string;
|
||||
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
|
||||
alias: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
path: string;
|
||||
session_alias: string;
|
||||
address: string;
|
||||
status: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
last_activity: string;
|
||||
/** 会话主题(多由 Agent 平台的模型生成的摘要) */
|
||||
subject: string;
|
||||
/** 本任务的往返预算(0 = 不限) */
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
permission_mode?: string;
|
||||
permission_enforcement?: string;
|
||||
/** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */
|
||||
last_from: string;
|
||||
last_preview: string;
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
path: string;
|
||||
session_alias: string;
|
||||
address: string;
|
||||
status: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
last_activity: string;
|
||||
/** 会话主题(多由 Agent 平台的模型生成的摘要) */
|
||||
subject: string;
|
||||
/** 本任务的往返预算(0 = 不限) */
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
permission_mode?: string;
|
||||
permission_enforcement?: string;
|
||||
/** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */
|
||||
last_from: string;
|
||||
last_preview: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
request_id: string;
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
context: string;
|
||||
result: string | null;
|
||||
decided_at: string | null;
|
||||
created_at: string;
|
||||
request_id: string;
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
context: string;
|
||||
result: string | null;
|
||||
decided_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
session: Session;
|
||||
mails: Mail[];
|
||||
session: Session;
|
||||
mails: Mail[];
|
||||
}
|
||||
|
||||
export interface HumanSession {
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
/** 本任务的往返预算(0 = 不限) */
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
permission_mode?: string;
|
||||
permission_enforcement?: string;
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
/** 本任务的往返预算(0 = 不限) */
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
permission_mode?: string;
|
||||
permission_enforcement?: string;
|
||||
}
|
||||
|
||||
export type SuggestKind = 'name' | 'path' | 'session';
|
||||
export type SuggestKind = "name" | "path" | "session";
|
||||
|
||||
/**
|
||||
* 会话候选项的来源。
|
||||
@ -249,38 +249,38 @@ export type SuggestKind = 'name' | 'path' | 'session';
|
||||
* platform 平台侧会话镜像(人直接在 opencode/DSH 界面上开的)
|
||||
* new 新建会话的哨兵项
|
||||
*/
|
||||
export type SessionCandidateSource = 'mail' | 'platform' | 'new';
|
||||
export type SessionCandidateSource = "mail" | "platform" | "new";
|
||||
|
||||
export interface SessionCandidate {
|
||||
/** 填进 session 位的值 */
|
||||
alias: string;
|
||||
/** 给人看,用来分辨两条别名相似的会话在谈什么 */
|
||||
title?: string;
|
||||
source: SessionCandidateSource;
|
||||
/** 仅 mail 来源有意义 */
|
||||
unread?: number;
|
||||
/** 填进 session 位的值 */
|
||||
alias: string;
|
||||
/** 给人看,用来分辨两条别名相似的会话在谈什么 */
|
||||
title?: string;
|
||||
source: SessionCandidateSource;
|
||||
/** 仅 mail 来源有意义 */
|
||||
unread?: number;
|
||||
}
|
||||
|
||||
export interface SuggestResult {
|
||||
kind: SuggestKind;
|
||||
suggestions: string[];
|
||||
/**
|
||||
* 带标题与来源的完整候选,与 suggestions 同序。
|
||||
* 仅 kind === 'session' 时返回;suggestions 保留纯字符串形式是为了
|
||||
* 不打破已部署的前端与第三方客户端。
|
||||
*/
|
||||
candidates?: SessionCandidate[];
|
||||
kind: SuggestKind;
|
||||
suggestions: string[];
|
||||
/**
|
||||
* 带标题与来源的完整候选,与 suggestions 同序。
|
||||
* 仅 kind === 'session' 时返回;suggestions 保留纯字符串形式是为了
|
||||
* 不打破已部署的前端与第三方客户端。
|
||||
*/
|
||||
candidates?: SessionCandidate[];
|
||||
}
|
||||
|
||||
/** 系统初始化状态 */
|
||||
export interface SetupStatus {
|
||||
needs_setup: boolean;
|
||||
needs_setup: boolean;
|
||||
}
|
||||
|
||||
/** 管理员可授权范围候选 */
|
||||
export interface AdminScopes {
|
||||
agents: string[];
|
||||
paths: string[];
|
||||
agents: string[];
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -291,58 +291,58 @@ export interface AdminScopes {
|
||||
* 对 Agent 来说就是一封普通邮件,它不知道也不需要知道信来自日历。
|
||||
*/
|
||||
export interface CalendarEvent {
|
||||
event_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/**
|
||||
* 提醒邮件的正文。留空时后端按 title/time/description 生成默认模板。
|
||||
* 支持 {title} {time} {description} 三个变量,触发时替换。
|
||||
*/
|
||||
reminder_text: string;
|
||||
/**
|
||||
* 收件 Agent 名 / 完整地址 —— **单收件人时代的字段**。
|
||||
* 保留作兼容与兜底:recipients 为空时才用它们。
|
||||
* 新代码一律用 effectiveRecipients()。
|
||||
*/
|
||||
agent_name: string;
|
||||
to_address: string;
|
||||
/**
|
||||
* 收件人列表,每项是完整三维地址串。
|
||||
*
|
||||
* 存原始串而不是结构化地址:session 位的 new/别名三态该在**触发那一刻**
|
||||
* 解析。存结构化的话「.new」这种一次性语义在建事件时就被固化,
|
||||
* 而重复事件每次触发都该重新决定落到哪条会话。
|
||||
*/
|
||||
recipients: string[];
|
||||
/**
|
||||
* 多收件人的投递方式。
|
||||
* separate = 各发一封、落各自会话、互相看不到
|
||||
* together = 首个为主收件人,其余进抄送、共享同一条线索
|
||||
*
|
||||
* 两种都要而不是二选一:「三个 Agent 各自独立汇报」与「pi 主办、dsh 知情」
|
||||
* 是完全不同的任务形态。用错 together 会让本该独立判断的 Agent 互相
|
||||
* 看到回复而趋同,那种污染事后无法分离。
|
||||
*/
|
||||
delivery_mode: DeliveryMode;
|
||||
/** ISO 8601 */
|
||||
event_time: string;
|
||||
/** 提前多少分钟提醒;0 = 到点才提醒 */
|
||||
remind_before: number;
|
||||
recurrence: Recurrence;
|
||||
/** 重复终止时间;越过它事件自动置为 cancelled */
|
||||
recurrence_end?: string;
|
||||
status: 'active' | 'paused' | 'cancelled';
|
||||
/** 上次触发的墙上时钟 */
|
||||
last_fired_at?: string;
|
||||
/**
|
||||
* 已触发的那个 occurrence(值 = 当时的 event_time)。
|
||||
* 去重靠它与 event_time 相等判断,不是拿 last_fired_at 比大小 ——
|
||||
* 后端 DueEvents 有 60 秒 lookahead,后者在窗口内恒为真会导致每 tick 重发。
|
||||
*/
|
||||
fired_for?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
event_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/**
|
||||
* 提醒邮件的正文。留空时后端按 title/time/description 生成默认模板。
|
||||
* 支持 {title} {time} {description} 三个变量,触发时替换。
|
||||
*/
|
||||
reminder_text: string;
|
||||
/**
|
||||
* 收件 Agent 名 / 完整地址 —— **单收件人时代的字段**。
|
||||
* 保留作兼容与兜底:recipients 为空时才用它们。
|
||||
* 新代码一律用 effectiveRecipients()。
|
||||
*/
|
||||
agent_name: string;
|
||||
to_address: string;
|
||||
/**
|
||||
* 收件人列表,每项是完整三维地址串。
|
||||
*
|
||||
* 存原始串而不是结构化地址:session 位的 new/别名三态该在**触发那一刻**
|
||||
* 解析。存结构化的话「.new」这种一次性语义在建事件时就被固化,
|
||||
* 而重复事件每次触发都该重新决定落到哪条会话。
|
||||
*/
|
||||
recipients: string[];
|
||||
/**
|
||||
* 多收件人的投递方式。
|
||||
* separate = 各发一封、落各自会话、互相看不到
|
||||
* together = 首个为主收件人,其余进抄送、共享同一条线索
|
||||
*
|
||||
* 两种都要而不是二选一:「三个 Agent 各自独立汇报」与「pi 主办、dsh 知情」
|
||||
* 是完全不同的任务形态。用错 together 会让本该独立判断的 Agent 互相
|
||||
* 看到回复而趋同,那种污染事后无法分离。
|
||||
*/
|
||||
delivery_mode: DeliveryMode;
|
||||
/** ISO 8601 */
|
||||
event_time: string;
|
||||
/** 提前多少分钟提醒;0 = 到点才提醒 */
|
||||
remind_before: number;
|
||||
recurrence: Recurrence;
|
||||
/** 重复终止时间;越过它事件自动置为 cancelled */
|
||||
recurrence_end?: string;
|
||||
status: "active" | "paused" | "cancelled";
|
||||
/** 上次触发的墙上时钟 */
|
||||
last_fired_at?: string;
|
||||
/**
|
||||
* 已触发的那个 occurrence(值 = 当时的 event_time)。
|
||||
* 去重靠它与 event_time 相等判断,不是拿 last_fired_at 比大小 ——
|
||||
* 后端 DueEvents 有 60 秒 lookahead,后者在窗口内恒为真会导致每 tick 重发。
|
||||
*/
|
||||
fired_for?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -353,38 +353,38 @@ export interface CalendarEvent {
|
||||
* 也没有 lunar_weekly(农历没有「周」这个单位)。
|
||||
*/
|
||||
export type Recurrence =
|
||||
| 'none'
|
||||
| 'daily'
|
||||
| 'weekly'
|
||||
| 'monthly'
|
||||
| 'yearly'
|
||||
| 'lunar_monthly'
|
||||
| 'lunar_yearly';
|
||||
| "none"
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly"
|
||||
| "yearly"
|
||||
| "lunar_monthly"
|
||||
| "lunar_yearly";
|
||||
|
||||
export type DeliveryMode = 'separate' | 'together';
|
||||
export type DeliveryMode = "separate" | "together";
|
||||
|
||||
/** 事件附件(随提醒邮件一起发出) */
|
||||
export interface CalendarAttachment {
|
||||
attachment_id: string;
|
||||
event_id: string;
|
||||
filename: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
attachment_id: string;
|
||||
event_id: string;
|
||||
filename: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 新建/编辑事件的请求体。event_time 必填,其余可省。 */
|
||||
export interface CalendarEventInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
reminder_text?: string;
|
||||
agent_name?: string;
|
||||
to_address?: string;
|
||||
recipients?: string[];
|
||||
delivery_mode?: DeliveryMode;
|
||||
event_time: string;
|
||||
remind_before?: number;
|
||||
recurrence?: Recurrence;
|
||||
recurrence_end?: string | null;
|
||||
status?: 'active' | 'paused' | 'cancelled';
|
||||
title: string;
|
||||
description?: string;
|
||||
reminder_text?: string;
|
||||
agent_name?: string;
|
||||
to_address?: string;
|
||||
recipients?: string[];
|
||||
delivery_mode?: DeliveryMode;
|
||||
event_time: string;
|
||||
remind_before?: number;
|
||||
recurrence?: Recurrence;
|
||||
recurrence_end?: string | null;
|
||||
status?: "active" | "paused" | "cancelled";
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
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 { 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';
|
||||
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";
|
||||
|
||||
/**
|
||||
* 权限决策面板。
|
||||
@ -19,187 +19,214 @@ import type { Mail } from '../../src/types';
|
||||
*/
|
||||
|
||||
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;
|
||||
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);
|
||||
});
|
||||
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() }));
|
||||
it("没有 permission_options 时给默认的同意/拒绝两个选项", () => {
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /同意/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /拒绝/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有 permission_options 时用它,且顺序保持', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['只这一次', '总是允许', '拒绝'] })
|
||||
})
|
||||
);
|
||||
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(['只这一次', '总是允许', '拒绝']);
|
||||
});
|
||||
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: ['只这一次', '拒绝'] })
|
||||
})
|
||||
);
|
||||
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: /只这一次/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /只这一次/ }));
|
||||
|
||||
// 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别
|
||||
// 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '只这一次', undefined)
|
||||
);
|
||||
});
|
||||
// 关键:不能归一化成 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() }));
|
||||
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 userEvent.type(
|
||||
screen.getByPlaceholderText("备注(可选)"),
|
||||
"只删 build,别动 dist",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '同意', '只删 build,别动 dist')
|
||||
);
|
||||
});
|
||||
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() }));
|
||||
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 userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
||||
|
||||
// 空串会在决策邮件里留一行空的「备注:」
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', '同意', undefined));
|
||||
});
|
||||
// 空串会在决策邮件里留一行空的「备注:」
|
||||
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() }));
|
||||
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 userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("已处理:")).toBeInTheDocument(),
|
||||
);
|
||||
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it('已经有 permission_result 的邮件直接显示结论', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_result: '拒绝' })
|
||||
})
|
||||
);
|
||||
it("已经有 permission_result 的邮件直接显示结论", () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_result: "拒绝" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText('拒绝')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
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() }));
|
||||
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: /同意/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
||||
|
||||
// 一次危险操作被批准两次,Agent 那边可能真的执行两遍
|
||||
await waitFor(() => {
|
||||
for (const b of screen.getAllByRole('button')) {
|
||||
expect(b).toBeDisabled();
|
||||
}
|
||||
});
|
||||
// 一次危险操作被批准两次,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());
|
||||
});
|
||||
// 收尾:让悬挂的 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() }));
|
||||
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: /同意/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
||||
|
||||
// 失败后显示「已处理」是最糟的结果:人以为批过了,Agent 还在等
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /同意/ })).toBeEnabled());
|
||||
expect(screen.queryByText('已处理:')).toBeNull();
|
||||
});
|
||||
// 失败后显示「已处理」是最糟的结果:人以为批过了,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);
|
||||
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: /同意/ }));
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /同意/ }));
|
||||
|
||||
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
|
||||
await waitFor(() => {
|
||||
expect(fetchInbox).toHaveBeenCalledWith('all');
|
||||
expect(selectSession).toHaveBeenCalledWith('s-1');
|
||||
});
|
||||
});
|
||||
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
|
||||
await waitFor(() => {
|
||||
expect(fetchInbox).toHaveBeenCalledWith("all");
|
||||
expect(selectSession).toHaveBeenCalledWith("s-1");
|
||||
});
|
||||
});
|
||||
|
||||
it('同意类选项用绿色,其余用红色', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['允许', 'approve', '拒绝', '算了'] })
|
||||
})
|
||||
);
|
||||
it("同意类选项用绿色,其余用红色", () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({
|
||||
permission_options: ["允许", "approve", "拒绝", "算了"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const cls = (name: string) =>
|
||||
screen.getByRole('button', { name: new RegExp(name) }).className;
|
||||
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');
|
||||
});
|
||||
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@ -210,117 +237,142 @@ describe('PermissionPanel 决策', () => {
|
||||
* - 不能把问题渲染成同意/拒绝(那会让人点出一个毫无意义的答案)
|
||||
* - 空回答不能提交(模型会拿到一个什么都没说的结果继续跑)
|
||||
*/
|
||||
describe('PermissionPanel 回答问题', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
|
||||
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
|
||||
});
|
||||
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 });
|
||||
const questionMail = (over: Partial<Mail> = {}): Mail =>
|
||||
permMail({ permission_kind: "question", ...over });
|
||||
|
||||
it('问题不带选项时:不渲染同意/拒绝,只给自由文本', () => {
|
||||
render(React.createElement(PermissionPanel, { mail: questionMail({ permission_options: [] }) }));
|
||||
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();
|
||||
});
|
||||
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'] })
|
||||
})
|
||||
);
|
||||
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();
|
||||
});
|
||||
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$/ });
|
||||
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');
|
||||
});
|
||||
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 })
|
||||
})
|
||||
);
|
||||
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: /提交回答/ }));
|
||||
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));
|
||||
});
|
||||
// 服务端按换行拆分多选答案,不能拼接成 "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: [] }) }));
|
||||
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();
|
||||
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')
|
||||
);
|
||||
});
|
||||
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'] })
|
||||
})
|
||||
);
|
||||
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 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', '但要先备份')
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith("m-1", "方案 A", "但要先备份"),
|
||||
);
|
||||
});
|
||||
|
||||
it('问题已回答过:显示结论,不再显示任何输入控件', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: questionMail({ permission_result: '方案 A' })
|
||||
})
|
||||
);
|
||||
it("问题已回答过:显示结论,不再显示任何输入控件", () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: questionMail({ permission_result: "方案 A" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText(/已处理/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
expect(screen.getByText(/已处理/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it('审批型(无 permission_kind)仍然走同意/拒绝路径', () => {
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
it("审批型(无 permission_kind)仍然走同意/拒绝路径", () => {
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
// 回归防护:question 分支不能把普通审批也带走
|
||||
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /提交回答/ })).toBeNull();
|
||||
});
|
||||
// 回归防护:question 分支不能把普通审批也带走
|
||||
expect(screen.getByRole("button", { name: /同意/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /提交回答/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user