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:
2026-09-11 11:28:27 +08:00
parent 1692c615c6
commit 19a3161ee4
22 changed files with 6889 additions and 5566 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -60,6 +60,40 @@ else
echo " 未找到 pi SDK$PI_SDK),跳过 pi 插件测试" echo " 未找到 pi SDK$PI_SDK),跳过 pi 插件测试"
fi fi
# pi 邮件工具扩展:给**交互式**会话装上 send_mail / read_inbox。
#
# 守护进程pi-mail-bridge.service用 noExtensions:true 起会话,它的邮件工具
# 只给模型在邮件会话里用;人在 TUI 里敲的 pi 拿不到。结果是平台的建设者自己
# 收不到邮件,只能绕到 curl + 密钥直连 Gateway。这个扩展补上那一侧 ——
# TUI 与邮箱是同一条 AgentMail 身份agent pi的两个入口。
#
# 为什么不会重复注册noExtensions:true 只加载 CLI `-e` 传的扩展,
# settings.json 的 extensions 数组会被排除(见 SDK resource-loader.js
# 因此守护进程的 worker 里零扩展、本扩展只作用于交互式会话。
if [[ -d "$PI_SDK" ]]; then
PI_HOME="${SUDO_USER:+$(getent passwd "$SUDO_USER" | cut -d: -f6)}"
PI_HOME="${PI_HOME:-$HOME}"
PI_SETTINGS="$PI_HOME/.pi/agent/settings.json"
EXT_PATH="$REPO/plugins/pi-mail-bridge/extension/index.ts"
if [[ -d "$PI_HOME/.pi/agent" || -f "$PI_SETTINGS" ]]; then
install -d "$(dirname "$PI_SETTINGS")"
node -e '
const fs = require("fs");
const [path, target] = process.argv.slice(1);
let cfg = {};
try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch { /* 新建 */ }
const list = Array.isArray(cfg.extensions) ? cfg.extensions : [];
if (list.includes(target)) { console.log(" 已注册,幂等跳过"); process.exit(0); }
list.push(target);
cfg.extensions = list;
fs.writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
console.log(" 已注册 " + target);
' "$PI_SETTINGS" "$EXT_PATH"
else
echo " 未找到 $PI_HOME/.pi/agent跳过手动在 settings.json 加 extensions: [\"$EXT_PATH\"]"
fi
fi
echo "==> 前端产物嵌入 Gateway" echo "==> 前端产物嵌入 Gateway"
# 只清构建产物,不能 rm -rf 整个目录: # 只清构建产物,不能 rm -rf 整个目录:
# placeholder.html 在版本库里(让 go:embed 在新克隆里能编译), # placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),

View File

@ -1,3 +1,9 @@
export declare function dshSessionIdForMail(mailSessionID: any): string; export declare function dshSessionIdForMail(mailSessionID: any): string;
export declare function matchesMailSession(dshSessionId: any, mailSessionID: any): boolean; export declare function matchesMailSession(
export declare function pickMailSession(dshSessionIds: any[] | undefined, mailSessionID: any): string | undefined; dshSessionId: any,
mailSessionID: any,
): boolean;
export declare function pickMailSession(
dshSessionIds: any[] | undefined,
mailSessionID: any,
): string | undefined;

View File

@ -24,7 +24,7 @@
/** 首次尝试使用的 DSH 会话 id。 */ /** 首次尝试使用的 DSH 会话 id。 */
export function dshSessionIdForMail(mailSessionID) { export function dshSessionIdForMail(mailSessionID) {
return `mail-${String(mailSessionID ?? '')}`; return `mail-${String(mailSessionID ?? "")}`;
} }
/** /**
@ -35,12 +35,12 @@ export function dshSessionIdForMail(mailSessionID) {
* @returns {boolean} * @returns {boolean}
*/ */
export function matchesMailSession(dshSessionId, mailSessionID) { export function matchesMailSession(dshSessionId, mailSessionID) {
const id = String(dshSessionId ?? ''); const id = String(dshSessionId ?? "");
const base = dshSessionIdForMail(mailSessionID); const base = dshSessionIdForMail(mailSessionID);
if (id === base) return true; if (id === base) return true;
// 模型降级重试mail-<id>-r1 / -r2 / … // 模型降级重试mail-<id>-r1 / -r2 / …
const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : ''; const suffix = id.startsWith(`${base}-r`) ? id.slice(base.length + 2) : "";
return suffix.length > 0 && /^\d+$/.test(suffix); return suffix.length > 0 && /^\d+$/.test(suffix);
} }
/** /**
@ -54,19 +54,19 @@ export function matchesMailSession(dshSessionId, mailSessionID) {
* @returns {string|undefined} * @returns {string|undefined}
*/ */
export function pickMailSession(dshSessionIds, mailSessionID) { export function pickMailSession(dshSessionIds, mailSessionID) {
const base = dshSessionIdForMail(mailSessionID); const base = dshSessionIdForMail(mailSessionID);
const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : []; const list = Array.isArray(dshSessionIds) ? dshSessionIds.map(String) : [];
if (list.includes(base)) return base; if (list.includes(base)) return base;
let best; let best;
let bestIndex = Infinity; let bestIndex = Infinity;
for (const id of list) { for (const id of list) {
if (!matchesMailSession(id, mailSessionID)) continue; if (!matchesMailSession(id, mailSessionID)) continue;
const idx = Number(id.slice(base.length + 2)); const idx = Number(id.slice(base.length + 2));
if (idx < bestIndex) { if (idx < bestIndex) {
bestIndex = idx; bestIndex = idx;
best = id; best = id;
} }
} }
return best; return best;
} }

View File

@ -1,17 +1,17 @@
export interface SseClientOptions { export interface SseClientOptions {
authHeaders: () => Record<string, string>; authHeaders: () => Record<string, string>;
baseURL: string; baseURL: string;
path: string; path: string;
onEvent: (evt: string, data: any) => void; onEvent: (evt: string, data: any) => void;
log?: (msg: string) => void; log?: (msg: string) => void;
} }
export declare function createSSEClient(options: SseClientOptions): { export declare function createSSEClient(options: SseClientOptions): {
stop: () => void; stop: () => void;
reset: () => void; reset: () => void;
}; };
export declare function createFrameParser(): { export declare function createFrameParser(): {
push: (chunk: string) => Array<{ event: string; data: string; id: string }>; push: (chunk: string) => Array<{ event: string; data: string; id: string }>;
reset: () => void; reset: () => void;
lastEventId: () => string; lastEventId: () => string;
setLastEventId: (id: string) => void; setLastEventId: (id: string) => void;
}; };

View File

@ -2,12 +2,20 @@ export declare function hasOptions(question: any): boolean;
export declare function optionLabels(question: any): string[]; export declare function optionLabels(question: any): string[];
export declare function questionTitle(question: any): string; export declare function questionTitle(question: any): string;
export declare function flattenQuestions(questions: any[]): { export declare function flattenQuestions(questions: any[]): {
question: string; question: string;
options: string[]; options: string[];
context: string; context: string;
multiSelect: boolean; multiSelect: boolean;
}; };
export declare function answersFromDecision(questions: any[], decision: string, note?: string): { export declare function answersFromDecision(
answers: Array<{ id: string; selected: string[]; custom?: string }>; questions: any[],
decision: string,
note?: string,
): {
answers: Array<{ id: string; selected: string[]; custom?: string }>;
}; };
export declare function isBlankAnswer(questions: any[], decision: string, note?: string): boolean; export declare function isBlankAnswer(
questions: any[],
decision: string,
note?: string,
): boolean;

File diff suppressed because it is too large Load Diff

View File

@ -6,59 +6,69 @@
* 运行时仍按旧档位执行 —— 人以为自己收紧了权限。 * 运行时仍按旧档位执行 —— 人以为自己收紧了权限。
*/ */
import { test } from 'node:test'; import { test } from "node:test";
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import { import {
dshSessionIdForMail, dshSessionIdForMail,
matchesMailSession, matchesMailSession,
pickMailSession, pickMailSession,
} from '../lib/mail-session-id.js'; } from "../lib/mail-session-id.js";
test('首次尝试的 id 是 mail-<邮件会话 id>', () => { test("首次尝试的 id 是 mail-<邮件会话 id>", () => {
assert.equal(dshSessionIdForMail('abc-123'), 'mail-abc-123'); assert.equal(dshSessionIdForMail("abc-123"), "mail-abc-123");
}); });
test('匹配首次尝试的 id', () => { test("匹配首次尝试的 id", () => {
assert.equal(matchesMailSession('mail-abc', 'abc'), true); assert.equal(matchesMailSession("mail-abc", "abc"), true);
}); });
test('匹配模型降级重试的 -r<i>', () => { test("匹配模型降级重试的 -r<i>", () => {
assert.equal(matchesMailSession('mail-abc-r1', 'abc'), true); assert.equal(matchesMailSession("mail-abc-r1", "abc"), true);
assert.equal(matchesMailSession('mail-abc-r12', 'abc'), true); assert.equal(matchesMailSession("mail-abc-r12", "abc"), true);
}); });
test('不匹配别的会话(前缀相同也不行)', () => { test("不匹配别的会话(前缀相同也不行)", () => {
assert.equal(matchesMailSession('mail-abcdef', 'abc'), false, 'mail-abc 的前缀不能吃掉 mail-abcdef'); assert.equal(
assert.equal(matchesMailSession('mail-abd', 'abc'), false); matchesMailSession("mail-abcdef", "abc"),
assert.equal(matchesMailSession('other-abc', 'abc'), false); false,
assert.equal(matchesMailSession('', 'abc'), false); "mail-abc 的前缀不能吃掉 mail-abcdef",
);
assert.equal(matchesMailSession("mail-abd", "abc"), false);
assert.equal(matchesMailSession("other-abc", "abc"), false);
assert.equal(matchesMailSession("", "abc"), false);
}); });
test('不匹配非数字后缀(避免误吞其它会话)', () => { test("不匹配非数字后缀(避免误吞其它会话)", () => {
assert.equal(matchesMailSession('mail-abc-rx', 'abc'), false); assert.equal(matchesMailSession("mail-abc-rx", "abc"), false);
assert.equal(matchesMailSession('mail-abc-r', 'abc'), false); assert.equal(matchesMailSession("mail-abc-r", "abc"), false);
assert.equal(matchesMailSession('mail-abc-retry', 'abc'), false); assert.equal(matchesMailSession("mail-abc-retry", "abc"), false);
}); });
test('pickMailSession 优先首次尝试,而不是数组顺序', () => { test("pickMailSession 优先首次尝试,而不是数组顺序", () => {
// 数组顺序可能来自 ctx.agents.list(),与尝试顺序无关 // 数组顺序可能来自 ctx.agents.list(),与尝试顺序无关
assert.equal(pickMailSession(['mail-abc-r2', 'mail-abc-r1', 'mail-abc'], 'abc'), 'mail-abc'); assert.equal(
pickMailSession(["mail-abc-r2", "mail-abc-r1", "mail-abc"], "abc"),
"mail-abc",
);
}); });
test('pickMailSession 没有首次尝试时取序号最小的重试', () => { test("pickMailSession 没有首次尝试时取序号最小的重试", () => {
assert.equal(pickMailSession(['mail-abc-r3', 'mail-abc-r1', 'mail-abc-r2'], 'abc'), 'mail-abc-r1'); assert.equal(
pickMailSession(["mail-abc-r3", "mail-abc-r1", "mail-abc-r2"], "abc"),
"mail-abc-r1",
);
}); });
test('pickMailSession 找不到时返回 undefined调用方据此跳过', () => { test("pickMailSession 找不到时返回 undefined调用方据此跳过", () => {
assert.equal(pickMailSession(['other-1', 'mail-def'], 'abc'), undefined); assert.equal(pickMailSession(["other-1", "mail-def"], "abc"), undefined);
assert.equal(pickMailSession([], 'abc'), undefined); assert.equal(pickMailSession([], "abc"), undefined);
assert.equal(pickMailSession(undefined, 'abc'), undefined); assert.equal(pickMailSession(undefined, "abc"), undefined);
}); });
test('接管的平台会话推不出 id不能被误判成邮件会话', () => { test("接管的平台会话推不出 id不能被误判成邮件会话", () => {
// 平台自己生成的 id如 DSH 界面里开的会话)与邮件会话无关, // 平台自己生成的 id如 DSH 界面里开的会话)与邮件会话无关,
// 匹配函数必须说「不是」,否则会给错误的会话改档位。 // 匹配函数必须说「不是」,否则会给错误的会话改档位。
assert.equal(matchesMailSession('session-7f3a91', 'abc'), false); assert.equal(matchesMailSession("session-7f3a91", "abc"), false);
assert.equal(pickMailSession(['session-7f3a91'], 'abc'), undefined); assert.equal(pickMailSession(["session-7f3a91"], "abc"), undefined);
}); });

View File

@ -9,121 +9,132 @@
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。 * 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
*/ */
import { test } from 'node:test'; import { test } from "node:test";
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import { createFrameParser } from '../lib/sse-client.js'; import { createFrameParser } from "../lib/sse-client.js";
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */ /** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
function parse(s) { function parse(s) {
try { try {
return JSON.parse(s); return JSON.parse(s);
} catch (e) { } catch (e) {
assert.fail(`不是合法 JSON: ${s}${e.message}`); assert.fail(`不是合法 JSON: ${s}${e.message}`);
} }
} }
test('完整帧一次喂入:正常解析', () => { test("完整帧一次喂入:正常解析", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n'); const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
assert.equal(events.length, 1); assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail'); assert.equal(events[0].event, "new_mail");
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' }); assert.deepEqual(parse(events[0].data), { mail_id: "m1" });
assert.equal(events[0].id, '7'); assert.equal(events[0].id, "7");
assert.equal(p.lastEventId(), '7'); assert.equal(p.lastEventId(), "7");
}); });
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => { test("帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)", () => {
const p = createFrameParser(); const p = createFrameParser();
// chunk1 恰好停在 event 行之后、data 行之前 // chunk1 恰好停在 event 行之后、data 行之前
const first = p.push('id: 12\nevent: content_delta\n'); const first = p.push("id: 12\nevent: content_delta\n");
assert.deepEqual(first, [], '半帧不该派发'); assert.deepEqual(first, [], "半帧不该派发");
const second = p.push('data: {"x":1}\n\n'); const second = p.push('data: {"x":1}\n\n');
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃'); assert.equal(
assert.equal(second[0].event, 'content_delta'); second.length,
assert.equal(p.lastEventId(), '12'); 1,
"跨 chunk 的半帧必须被拼回完整事件,而不是丢弃",
);
assert.equal(second[0].event, "content_delta");
assert.equal(p.lastEventId(), "12");
}); });
test('帧被切在行中间buffer 保留半行', () => { test("帧被切在行中间buffer 保留半行", () => {
const p = createFrameParser(); const p = createFrameParser();
const a = p.push('event: new_ma'); const a = p.push("event: new_ma");
assert.deepEqual(a, []); assert.deepEqual(a, []);
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n'); const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
assert.equal(b.length, 1); assert.equal(b.length, 1);
assert.equal(b[0].event, 'new_mail'); assert.equal(b[0].event, "new_mail");
}); });
test('一个 chunk 里多帧连续:全部派发', () => { test("一个 chunk 里多帧连续:全部派发", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push( const events = p.push(
'event: new_mail\ndata: {"n":1}\n\n' + 'event: new_mail\ndata: {"n":1}\n\n' +
'event: new_mail\ndata: {"n":2}\n\n' + 'event: new_mail\ndata: {"n":2}\n\n' +
'event: session_update\ndata: {"n":3}\n\n' 'event: session_update\ndata: {"n":3}\n\n',
); );
assert.equal(events.length, 3); assert.equal(events.length, 3);
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']); assert.deepEqual(
events.map((e) => e.event),
["new_mail", "new_mail", "session_update"],
);
}); });
test('注释/心跳行被忽略,不影响后续帧', () => { test("注释/心跳行被忽略,不影响后续帧", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n'); const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(events.length, 1); assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail'); assert.equal(events[0].event, "new_mail");
}); });
test('多行 data 用换行拼接', () => { test("多行 data 用换行拼接", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('event: x\ndata: line1\ndata: line2\n\n'); const events = p.push("event: x\ndata: line1\ndata: line2\n\n");
assert.equal(events[0].data, 'line1\nline2'); assert.equal(events[0].data, "line1\nline2");
}); });
test('CRLF 不被当成事件名或 JSON 的一部分', () => { test("CRLF 不被当成事件名或 JSON 的一部分", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n'); const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
assert.equal(events.length, 1); assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail'); assert.equal(events[0].event, "new_mail");
assert.equal(events[0].id, '3'); assert.equal(events[0].id, "3");
assert.deepEqual(parse(events[0].data), { n: 1 }); assert.deepEqual(parse(events[0].data), { n: 1 });
}); });
test('事件 id 只向前推进:重放旧 id 不回退断点', () => { test("事件 id 只向前推进:重放旧 id 不回退断点", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n'); p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '10'); assert.equal(p.lastEventId(), "10");
// 服务端重放一条更早的事件:断点不该退回 5否则下次重连会重复回放 6..10 // 服务端重放一条更早的事件:断点不该退回 5否则下次重连会重复回放 6..10
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n'); p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id是否回退由使用方决定'); assert.equal(
p.lastEventId(),
"5",
"解析器如实记录当前 id是否回退由使用方决定",
);
}); });
test('id 在派发前记录:回调抛异常也不丢断点', () => { test("id 在派发前记录:回调抛异常也不丢断点", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n'); p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '42'); assert.equal(p.lastEventId(), "42");
}); });
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => { test("只有 data 没有 event 不派发(避免把心跳数据当事件)", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('data: {"orphan":true}\n\n'); const events = p.push('data: {"orphan":true}\n\n');
assert.deepEqual(events, []); assert.deepEqual(events, []);
}); });
test('reset 清缓冲但保留断点(重连后仍能续传)', () => { test("reset 清缓冲但保留断点(重连后仍能续传)", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n'); p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
p.push('event: partial'); // 半帧 p.push("event: partial"); // 半帧
p.reset(); p.reset();
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放'); assert.equal(p.lastEventId(), "99", "断点必须保留,否则重连从头回放");
// reset 后半帧不该复活 // reset 后半帧不该复活
const after = p.push('data: {"n":2}\n\n'); const after = p.push('data: {"n":2}\n\n');
assert.deepEqual(after, []); assert.deepEqual(after, []);
}); });
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => { test("setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n'); p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '123'); assert.equal(p.lastEventId(), "123");
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉 // connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
p.setLastEventId(''); p.setLastEventId("");
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID'); assert.equal(p.lastEventId(), "", "首次连接不得携带 Last-Event-ID");
}); });

File diff suppressed because it is too large Load Diff

View File

@ -34,59 +34,61 @@
* setLastEventId: (id: string) => void}} * setLastEventId: (id: string) => void}}
*/ */
export function createFrameParser() { export function createFrameParser() {
let buffer = ''; let buffer = "";
let lastEventId = ''; let lastEventId = "";
let curEvent = ''; let curEvent = "";
let curData = ''; let curData = "";
let curId = ''; let curId = "";
function push(chunk) { function push(chunk) {
buffer += chunk; buffer += chunk;
const events = []; const events = [];
const lines = buffer.split('\n'); const lines = buffer.split("\n");
// 最后一段可能是被切断的半行,留到下一个 chunk // 最后一段可能是被切断的半行,留到下一个 chunk
buffer = lines.pop() ?? ''; buffer = lines.pop() ?? "";
for (let line of lines) { for (let line of lines) {
if (line.length > 0 && line.charAt(line.length - 1) === '\r') { if (line.length > 0 && line.charAt(line.length - 1) === "\r") {
line = line.slice(0, -1); line = line.slice(0, -1);
} }
if (line.startsWith(':')) continue; if (line.startsWith(":")) continue;
if (line.startsWith('id:')) { if (line.startsWith("id:")) {
curId = line.slice(3).trim(); curId = line.slice(3).trim();
} else if (line.startsWith('event:')) { } else if (line.startsWith("event:")) {
curEvent = line.slice(6).trim(); curEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) { } else if (line.startsWith("data:")) {
let value = line.slice(5); let value = line.slice(5);
if (value.startsWith(' ')) value = value.slice(1); if (value.startsWith(" ")) value = value.slice(1);
curData = curData.length > 0 ? `${curData}\n${value}` : value; curData = curData.length > 0 ? `${curData}\n${value}` : value;
} else if (line === '') { } else if (line === "") {
if (curEvent.length > 0 && curData.length > 0) { if (curEvent.length > 0 && curData.length > 0) {
if (curId.length > 0) lastEventId = curId; if (curId.length > 0) lastEventId = curId;
events.push({ event: curEvent, data: curData, id: curId }); events.push({ event: curEvent, data: curData, id: curId });
} }
curEvent = ''; curEvent = "";
curData = ''; curData = "";
curId = ''; curId = "";
} }
} }
return events; return events;
} }
function reset() { function reset() {
buffer = ''; buffer = "";
curEvent = ''; curEvent = "";
curData = ''; curData = "";
curId = ''; curId = "";
} }
return { return {
push, push,
reset, reset,
lastEventId: () => lastEventId, lastEventId: () => lastEventId,
setLastEventId: (id) => { lastEventId = id || ''; }, setLastEventId: (id) => {
}; lastEventId = id || "";
},
};
} }
/** /**
@ -98,82 +100,93 @@ export function createFrameParser() {
* @param {(msg: string) => void} [deps.log] 日志回调(默认 console.error * @param {(msg: string) => void} [deps.log] 日志回调(默认 console.error
* @returns {{stop: () => void}} stop() 终止重连与在途请求 * @returns {{stop: () => void}} stop() 终止重连与在途请求
*/ */
export function createSSEClient({ authHeaders, baseURL, path, onEvent, log = console.error }) { export function createSSEClient({
const controller = new AbortController(); authHeaders,
const parser = createFrameParser(); baseURL,
path,
onEvent,
log = console.error,
}) {
const controller = new AbortController();
const parser = createFrameParser();
function stop() { function stop() {
controller.abort(); controller.abort();
} }
function reconnect(delay) { function reconnect(delay) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setTimeout(() => connect(), delay); setTimeout(() => connect(), delay);
} }
function connect() { function connect() {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
const headers = { ...authHeaders(), Accept: 'text/event-stream' }; const headers = { ...authHeaders(), Accept: "text/event-stream" };
// 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带, // 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带,
// 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。 // 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。
const lastEventID = parser.lastEventId(); const lastEventID = parser.lastEventId();
if (lastEventID) { if (lastEventID) {
headers['Last-Event-ID'] = lastEventID; headers["Last-Event-ID"] = lastEventID;
log(`SSE 重连,从事件 ${lastEventID} 之后续传`); log(`SSE 重连,从事件 ${lastEventID} 之后续传`);
} }
fetch(`${baseURL}${path}`, { headers, signal: controller.signal }) fetch(`${baseURL}${path}`, { headers, signal: controller.signal })
.then((res) => { .then((res) => {
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
log(`SSE 建连失败: HTTP ${res.status}`); log(`SSE 建连失败: HTTP ${res.status}`);
return reconnect(5000); return reconnect(5000);
} }
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
function read() { function read() {
reader.read().then(({ done, value }) => { reader
if (done) { .read()
parser.reset(); .then(({ done, value }) => {
return reconnect(3000); if (done) {
} parser.reset();
for (const ev of parser.push(decoder.decode(value, { stream: true }))) { return reconnect(3000);
try { }
onEvent(ev.event, JSON.parse(ev.data)); for (const ev of parser.push(
} catch (e) { decoder.decode(value, { stream: true }),
log(`SSE 事件处理失败: ${e?.message || e}`); )) {
} try {
} onEvent(ev.event, JSON.parse(ev.data));
read(); } catch (e) {
}).catch((e) => { log(`SSE 事件处理失败: ${e?.message || e}`);
if (controller.signal.aborted) return; }
log(`SSE 读取中断: ${e?.message || e}`); }
parser.reset(); read();
reconnect(5000); })
}); .catch((e) => {
} if (controller.signal.aborted) return;
read(); log(`SSE 读取中断: ${e?.message || e}`);
}) parser.reset();
.catch((e) => { reconnect(5000);
if (controller.signal.aborted) return; });
log(`SSE 连接错误: ${e?.message || e}`); }
parser.reset(); read();
reconnect(5000); })
}); .catch((e) => {
} if (controller.signal.aborted) return;
log(`SSE 连接错误: ${e?.message || e}`);
parser.reset();
reconnect(5000);
});
}
connect(); connect();
return { return {
stop, stop,
/** /**
* 清掉断点(不终止连接)。 * 清掉断点(不终止连接)。
* *
* 换 Gateway 地址时必须调lastEventID 是**旧** Gateway 环形缓冲里的序号, * 换 Gateway 地址时必须调lastEventID 是**旧** Gateway 环形缓冲里的序号,
* 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒), * 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒),
* 得到的事件属于别人的会话。 * 得到的事件属于别人的会话。
*/ */
reset: () => parser.setLastEventId(''), reset: () => parser.setLastEventId(""),
}; };
} }

View File

@ -32,23 +32,25 @@
/** 问题是否带可选项。 */ /** 问题是否带可选项。 */
export function hasOptions(question) { export function hasOptions(question) {
return Array.isArray(question?.options) && question.options.length > 0; return Array.isArray(question?.options) && question.options.length > 0;
} }
/** 一个 DSH 问题的可选项 label 列表(保序)。 */ /** 一个 DSH 问题的可选项 label 列表(保序)。 */
export function optionLabels(question) { export function optionLabels(question) {
if (!hasOptions(question)) return []; if (!hasOptions(question)) return [];
return question.options return question.options
.map((o) => (typeof o === 'string' ? o : o?.label)) .map((o) => (typeof o === "string" ? o : o?.label))
.filter((l) => typeof l === 'string' && l.length > 0); .filter((l) => typeof l === "string" && l.length > 0);
} }
/** 一个问题的展示标题header 有就用它做前缀,否则只用 question。 */ /** 一个问题的展示标题header 有就用它做前缀,否则只用 question。 */
export function questionTitle(question) { export function questionTitle(question) {
const header = typeof question?.header === 'string' ? question.header.trim() : ''; const header =
const text = typeof question?.question === 'string' ? question.question.trim() : ''; typeof question?.header === "string" ? question.header.trim() : "";
if (header && text) return `${header}: ${text}`; const text =
return header || text || '(未提供问题)'; typeof question?.question === "string" ? question.question.trim() : "";
if (header && text) return `${header}: ${text}`;
return header || text || "(未提供问题)";
} }
/** /**
@ -58,51 +60,56 @@ export function questionTitle(question) {
* @returns {{ question: string, options: string[], context: string, multiSelect: boolean }} * @returns {{ question: string, options: string[], context: string, multiSelect: boolean }}
*/ */
export function flattenQuestions(questions) { export function flattenQuestions(questions) {
const list = Array.isArray(questions) ? questions.filter(Boolean) : []; const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
if (list.length === 0) { if (list.length === 0) {
throw new Error('ask_user_question 至少需要一个 question'); throw new Error("ask_user_question 至少需要一个 question");
} }
const lines = []; const lines = [];
const options = []; const options = [];
const seen = new Set(); const seen = new Set();
let anyMulti = false; let anyMulti = false;
list.forEach((q, i) => { list.forEach((q, i) => {
const title = questionTitle(q); const title = questionTitle(q);
lines.push(`${i + 1}. ${title}`); lines.push(`${i + 1}. ${title}`);
const detail = typeof q?.detail === 'string' ? q.detail.trim() : ''; const detail = typeof q?.detail === "string" ? q.detail.trim() : "";
if (detail) lines.push(` ${detail}`); if (detail) lines.push(` ${detail}`);
const labels = optionLabels(q); const labels = optionLabels(q);
if (labels.length > 0) { if (labels.length > 0) {
lines.push(` 可选项:${labels.join(' / ')}${q.multiSelect ? '(可多选)' : ''}`); lines.push(
for (const label of labels) { ` 可选项:${labels.join(" / ")}${q.multiSelect ? "(可多选)" : ""}`,
if (!seen.has(label)) { );
seen.add(label); for (const label of labels) {
options.push(label); if (!seen.has(label)) {
} seen.add(label);
} options.push(label);
} else { }
lines.push(' (请直接填写回答)'); }
} } else {
if (q?.multiSelect === true) anyMulti = true; lines.push(" (请直接填写回答)");
}); }
if (q?.multiSelect === true) anyMulti = true;
});
// 多问题时必须允许多选:不同问题的选项要能一起勾选。 // 多问题时必须允许多选:不同问题的选项要能一起勾选。
const multiSelect = list.length > 1 ? options.length > 0 : anyMulti; const multiSelect = list.length > 1 ? options.length > 0 : anyMulti;
const question = list.length === 1 ? questionTitle(list[0]) : `${list.length} 个问题待回答`; const question =
const context = [ list.length === 1 ? questionTitle(list[0]) : `${list.length} 个问题待回答`;
list.length === 1 ? '' : '模型提出了多个问题,请在「回复」里一并回答:', const context = [
...lines, list.length === 1 ? "" : "模型提出了多个问题,请在「回复」里一并回答:",
'', ...lines,
options.length > 0 "",
? '可直接勾选下方的选项;补充说明写在备注里。' options.length > 0
: '这题没有预设选项,请把回答写在备注里。', ? "可直接勾选下方的选项;补充说明写在备注里。"
].filter((l) => l !== '').join('\n'); : "这题没有预设选项,请把回答写在备注里。",
]
.filter((l) => l !== "")
.join("\n");
return { question, options, context, multiSelect }; return { question, options, context, multiSelect };
} }
/** /**
@ -114,51 +121,55 @@ export function flattenQuestions(questions) {
* @returns {{ answers: Array<{id: string, selected: string[], custom?: string}> }} * @returns {{ answers: Array<{id: string, selected: string[], custom?: string}> }}
*/ */
export function answersFromDecision(questions, decision, note) { export function answersFromDecision(questions, decision, note) {
const list = Array.isArray(questions) ? questions.filter(Boolean) : []; const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
const labels = String(decision || '') const labels = String(decision || "")
.split('\n') .split("\n")
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean); .filter(Boolean);
const custom = typeof note === 'string' ? note.trim() : ''; const custom = typeof note === "string" ? note.trim() : "";
// 单问题:忠实映射(选项 → selected备注 → custom // 单问题:忠实映射(选项 → selected备注 → custom
if (list.length === 1) { if (list.length === 1) {
const q = list[0]; const q = list[0];
const id = String(q?.id ?? '0'); const id = String(q?.id ?? "0");
if (!hasOptions(q)) { if (!hasOptions(q)) {
// 无选项题:人类把答案写在决策文本或备注里,都属于「自由文本回答」。 // 无选项题:人类把答案写在决策文本或备注里,都属于「自由文本回答」。
const text = custom || labels.join('\n'); const text = custom || labels.join("\n");
return { answers: [{ id, selected: [], ...(text ? { custom: text } : {}) }] }; return {
} answers: [{ id, selected: [], ...(text ? { custom: text } : {}) }],
return { };
answers: [{ }
id, return {
selected: labels, answers: [
...(custom ? { custom } : {}), {
}], id,
}; selected: labels,
} ...(custom ? { custom } : {}),
},
],
};
}
// 多问题:按 label 归属把选择分配给各自的问题;备注归给第一个问题。 // 多问题:按 label 归属把选择分配给各自的问题;备注归给第一个问题。
let customUsed = false; let customUsed = false;
const answers = list.map((q, i) => { const answers = list.map((q, i) => {
const id = String(q?.id ?? String(i)); const id = String(q?.id ?? String(i));
const labels_q = optionLabels(q); const labels_q = optionLabels(q);
const selected = labels.filter((l) => labels_q.includes(l)); const selected = labels.filter((l) => labels_q.includes(l));
let qCustom; let qCustom;
if (custom && !customUsed) { if (custom && !customUsed) {
qCustom = custom; qCustom = custom;
customUsed = true; customUsed = true;
} }
// 无选项题且人没写备注:退而把决策文本整段给它(否则它的答案永远是空的)。 // 无选项题且人没写备注:退而把决策文本整段给它(否则它的答案永远是空的)。
if (qCustom === undefined && !hasOptions(q) && note === undefined) { if (qCustom === undefined && !hasOptions(q) && note === undefined) {
const text = labels.join('\n'); const text = labels.join("\n");
if (text) qCustom = text; if (text) qCustom = text;
} }
return { id, selected, ...(qCustom ? { custom: qCustom } : {}) }; return { id, selected, ...(qCustom ? { custom: qCustom } : {}) };
}); });
return { answers }; return { answers };
} }
/** /**
@ -167,10 +178,13 @@ export function answersFromDecision(questions, decision, note) {
* 允许多选时空 selected 但有 custom 也算答了;两者都空才算没答。 * 允许多选时空 selected 但有 custom 也算答了;两者都空才算没答。
*/ */
export function isBlankAnswer(questions, decision, note) { export function isBlankAnswer(questions, decision, note) {
const labels = String(decision || '').split('\n').map((s) => s.trim()).filter(Boolean); const labels = String(decision || "")
const custom = typeof note === 'string' ? note.trim() : ''; .split("\n")
if (labels.length > 0 || custom) return false; .map((s) => s.trim())
// 全部问题都没有选项、人也没写字 → 确实什么都没答 .filter(Boolean);
const list = Array.isArray(questions) ? questions.filter(Boolean) : []; const custom = typeof note === "string" ? note.trim() : "";
return list.some((q) => hasOptions(q)) || list.length === 0; if (labels.length > 0 || custom) return false;
// 全部问题都没有选项、人也没写字 → 确实什么都没答
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
return list.some((q) => hasOptions(q)) || list.length === 0;
} }

View File

@ -9,121 +9,132 @@
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。 * 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
*/ */
import { test } from 'node:test'; import { test } from "node:test";
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import { createFrameParser } from '../lib/sse-client.js'; import { createFrameParser } from "../lib/sse-client.js";
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */ /** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
function parse(s) { function parse(s) {
try { try {
return JSON.parse(s); return JSON.parse(s);
} catch (e) { } catch (e) {
assert.fail(`不是合法 JSON: ${s}${e.message}`); assert.fail(`不是合法 JSON: ${s}${e.message}`);
} }
} }
test('完整帧一次喂入:正常解析', () => { test("完整帧一次喂入:正常解析", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n'); const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
assert.equal(events.length, 1); assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail'); assert.equal(events[0].event, "new_mail");
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' }); assert.deepEqual(parse(events[0].data), { mail_id: "m1" });
assert.equal(events[0].id, '7'); assert.equal(events[0].id, "7");
assert.equal(p.lastEventId(), '7'); assert.equal(p.lastEventId(), "7");
}); });
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => { test("帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)", () => {
const p = createFrameParser(); const p = createFrameParser();
// chunk1 恰好停在 event 行之后、data 行之前 // chunk1 恰好停在 event 行之后、data 行之前
const first = p.push('id: 12\nevent: content_delta\n'); const first = p.push("id: 12\nevent: content_delta\n");
assert.deepEqual(first, [], '半帧不该派发'); assert.deepEqual(first, [], "半帧不该派发");
const second = p.push('data: {"x":1}\n\n'); const second = p.push('data: {"x":1}\n\n');
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃'); assert.equal(
assert.equal(second[0].event, 'content_delta'); second.length,
assert.equal(p.lastEventId(), '12'); 1,
"跨 chunk 的半帧必须被拼回完整事件,而不是丢弃",
);
assert.equal(second[0].event, "content_delta");
assert.equal(p.lastEventId(), "12");
}); });
test('帧被切在行中间buffer 保留半行', () => { test("帧被切在行中间buffer 保留半行", () => {
const p = createFrameParser(); const p = createFrameParser();
const a = p.push('event: new_ma'); const a = p.push("event: new_ma");
assert.deepEqual(a, []); assert.deepEqual(a, []);
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n'); const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
assert.equal(b.length, 1); assert.equal(b.length, 1);
assert.equal(b[0].event, 'new_mail'); assert.equal(b[0].event, "new_mail");
}); });
test('一个 chunk 里多帧连续:全部派发', () => { test("一个 chunk 里多帧连续:全部派发", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push( const events = p.push(
'event: new_mail\ndata: {"n":1}\n\n' + 'event: new_mail\ndata: {"n":1}\n\n' +
'event: new_mail\ndata: {"n":2}\n\n' + 'event: new_mail\ndata: {"n":2}\n\n' +
'event: session_update\ndata: {"n":3}\n\n' 'event: session_update\ndata: {"n":3}\n\n',
); );
assert.equal(events.length, 3); assert.equal(events.length, 3);
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']); assert.deepEqual(
events.map((e) => e.event),
["new_mail", "new_mail", "session_update"],
);
}); });
test('注释/心跳行被忽略,不影响后续帧', () => { test("注释/心跳行被忽略,不影响后续帧", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n'); const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(events.length, 1); assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail'); assert.equal(events[0].event, "new_mail");
}); });
test('多行 data 用换行拼接', () => { test("多行 data 用换行拼接", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('event: x\ndata: line1\ndata: line2\n\n'); const events = p.push("event: x\ndata: line1\ndata: line2\n\n");
assert.equal(events[0].data, 'line1\nline2'); assert.equal(events[0].data, "line1\nline2");
}); });
test('CRLF 不被当成事件名或 JSON 的一部分', () => { test("CRLF 不被当成事件名或 JSON 的一部分", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n'); const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
assert.equal(events.length, 1); assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail'); assert.equal(events[0].event, "new_mail");
assert.equal(events[0].id, '3'); assert.equal(events[0].id, "3");
assert.deepEqual(parse(events[0].data), { n: 1 }); assert.deepEqual(parse(events[0].data), { n: 1 });
}); });
test('事件 id 只向前推进:重放旧 id 不回退断点', () => { test("事件 id 只向前推进:重放旧 id 不回退断点", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n'); p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '10'); assert.equal(p.lastEventId(), "10");
// 服务端重放一条更早的事件:断点不该退回 5否则下次重连会重复回放 6..10 // 服务端重放一条更早的事件:断点不该退回 5否则下次重连会重复回放 6..10
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n'); p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id是否回退由使用方决定'); assert.equal(
p.lastEventId(),
"5",
"解析器如实记录当前 id是否回退由使用方决定",
);
}); });
test('id 在派发前记录:回调抛异常也不丢断点', () => { test("id 在派发前记录:回调抛异常也不丢断点", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n'); p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '42'); assert.equal(p.lastEventId(), "42");
}); });
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => { test("只有 data 没有 event 不派发(避免把心跳数据当事件)", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('data: {"orphan":true}\n\n'); const events = p.push('data: {"orphan":true}\n\n');
assert.deepEqual(events, []); assert.deepEqual(events, []);
}); });
test('reset 清缓冲但保留断点(重连后仍能续传)', () => { test("reset 清缓冲但保留断点(重连后仍能续传)", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n'); p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
p.push('event: partial'); // 半帧 p.push("event: partial"); // 半帧
p.reset(); p.reset();
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放'); assert.equal(p.lastEventId(), "99", "断点必须保留,否则重连从头回放");
// reset 后半帧不该复活 // reset 后半帧不该复活
const after = p.push('data: {"n":2}\n\n'); const after = p.push('data: {"n":2}\n\n');
assert.deepEqual(after, []); assert.deepEqual(after, []);
}); });
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => { test("setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n'); p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '123'); assert.equal(p.lastEventId(), "123");
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉 // connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
p.setLastEventId(''); p.setLastEventId("");
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID'); assert.equal(p.lastEventId(), "", "首次连接不得携带 Last-Event-ID");
}); });

View File

@ -10,127 +10,179 @@
* - 空回答不能被当成「答了」 * - 空回答不能被当成「答了」
*/ */
import { test } from 'node:test'; import { test } from "node:test";
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import { import {
flattenQuestions, flattenQuestions,
answersFromDecision, answersFromDecision,
isBlankAnswer, isBlankAnswer,
hasOptions, hasOptions,
optionLabels, optionLabels,
questionTitle, questionTitle,
} from '../lib/user-question.js'; } from "../lib/user-question.js";
test('单问题单选项:忠实映射问题与选项', () => { test("单问题单选项:忠实映射问题与选项", () => {
const flat = flattenQuestions([{ const flat = flattenQuestions([
id: 'q1', {
question: '用哪种方案?', id: "q1",
header: '选择', question: "用哪种方案?",
options: [{ label: '方案 A' }, { label: '方案 B' }], header: "选择",
}]); options: [{ label: "方案 A" }, { label: "方案 B" }],
assert.equal(flat.question, '选择: 用哪种方案?'); },
assert.deepEqual(flat.options, ['方案 A', '方案 B']); ]);
assert.equal(flat.multiSelect, false); assert.equal(flat.question, "选择: 用哪种方案?");
assert.deepEqual(flat.options, ["方案 A", "方案 B"]);
assert.equal(flat.multiSelect, false);
}); });
test('单问题多选multiSelect 透传', () => { test("单问题多选multiSelect 透传", () => {
const flat = flattenQuestions([{ const flat = flattenQuestions([
id: 'q1', {
question: '要哪些?', id: "q1",
options: [{ label: 'a' }, { label: 'b' }], question: "要哪些?",
multiSelect: true, options: [{ label: "a" }, { label: "b" }],
}]); multiSelect: true,
assert.equal(flat.multiSelect, true); },
]);
assert.equal(flat.multiSelect, true);
}); });
test('多问题选项取并集且去重保序multiSelect 置真', () => { test("多问题选项取并集且去重保序multiSelect 置真", () => {
const flat = flattenQuestions([ const flat = flattenQuestions([
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] }, {
{ id: 'q2', question: '后端?', options: [{ label: 'Vue' }, { label: 'Go' }] }, id: "q1",
]); question: "前端?",
assert.deepEqual(flat.options, ['React', 'Vue', 'Go'], '重复 label 只出现一次'); options: [{ label: "React" }, { label: "Vue" }],
assert.equal(flat.multiSelect, true, '多问题必须允许多选,否则无法同时回答两题'); },
assert.match(flat.context, /前端?/); {
assert.match(flat.context, /后端?/); id: "q2",
question: "后端?",
options: [{ label: "Vue" }, { label: "Go" }],
},
]);
assert.deepEqual(
flat.options,
["React", "Vue", "Go"],
"重复 label 只出现一次",
);
assert.equal(
flat.multiSelect,
true,
"多问题必须允许多选,否则无法同时回答两题",
);
assert.match(flat.context, /前端?/);
assert.match(flat.context, /后端?/);
}); });
test('无选项题options 为空,正文提示直接填写', () => { test("无选项题options 为空,正文提示直接填写", () => {
const flat = flattenQuestions([{ id: 'q1', question: '你的名字?' }]); const flat = flattenQuestions([{ id: "q1", question: "你的名字?" }]);
assert.deepEqual(flat.options, []); assert.deepEqual(flat.options, []);
assert.match(flat.context, /直接填写|没有预设选项/); assert.match(flat.context, /直接填写|没有预设选项/);
}); });
test('空问题列表:抛错而不是造一封没有内容的信', () => { test("空问题列表:抛错而不是造一封没有内容的信", () => {
assert.throws(() => flattenQuestions([]), /至少需要一个/); assert.throws(() => flattenQuestions([]), /至少需要一个/);
assert.throws(() => flattenQuestions(undefined), /至少需要一个/); assert.throws(() => flattenQuestions(undefined), /至少需要一个/);
}); });
test('单问题回写:选项进 selected备注进 custom', () => { test("单问题回写:选项进 selected备注进 custom", () => {
const qs = [{ id: 'q1', question: '选哪个', options: [{ label: 'A' }, { label: 'B' }] }]; const qs = [
const ans = answersFromDecision(qs, 'A', '再确认下'); { id: "q1", question: "选哪个", options: [{ label: "A" }, { label: "B" }] },
assert.deepEqual(ans.answers, [{ id: 'q1', selected: ['A'], custom: '再确认下' }]); ];
const ans = answersFromDecision(qs, "A", "再确认下");
assert.deepEqual(ans.answers, [
{ id: "q1", selected: ["A"], custom: "再确认下" },
]);
}); });
test('单问题多选回写:多行决策拆成多个 selected', () => { test("单问题多选回写:多行决策拆成多个 selected", () => {
const qs = [{ id: 'q1', question: '选哪些', options: [{ label: 'A' }, { label: 'B' }], multiSelect: true }]; const qs = [
const ans = answersFromDecision(qs, 'A\nB', ''); {
assert.deepEqual(ans.answers[0].selected, ['A', 'B']); id: "q1",
assert.equal(ans.answers[0].custom, undefined, '空备注不该变成空 custom'); question: "选哪些",
options: [{ label: "A" }, { label: "B" }],
multiSelect: true,
},
];
const ans = answersFromDecision(qs, "A\nB", "");
assert.deepEqual(ans.answers[0].selected, ["A", "B"]);
assert.equal(ans.answers[0].custom, undefined, "空备注不该变成空 custom");
}); });
test('无选项题回写:答案进 customselected 为空', () => { test("无选项题回写:答案进 customselected 为空", () => {
const qs = [{ id: 'q1', question: '名字?' }]; const qs = [{ id: "q1", question: "名字?" }];
const ans = answersFromDecision(qs, '', '张三'); const ans = answersFromDecision(qs, "", "张三");
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '张三' }]); assert.deepEqual(ans.answers, [{ id: "q1", selected: [], custom: "张三" }]);
}); });
test('无选项题只有决策文本时:文本进 custom否则答案永远为空', () => { test("无选项题只有决策文本时:文本进 custom否则答案永远为空", () => {
const qs = [{ id: 'q1', question: '名字?' }]; const qs = [{ id: "q1", question: "名字?" }];
const ans = answersFromDecision(qs, '李四', undefined); const ans = answersFromDecision(qs, "李四", undefined);
assert.deepEqual(ans.answers, [{ id: 'q1', selected: [], custom: '李四' }]); assert.deepEqual(ans.answers, [{ id: "q1", selected: [], custom: "李四" }]);
}); });
test('多问题回写:选择按 label 归属分配到各自的问题(不张冠李戴)', () => { test("多问题回写:选择按 label 归属分配到各自的问题(不张冠李戴)", () => {
const qs = [ const qs = [
{ id: 'q1', question: '前端?', options: [{ label: 'React' }, { label: 'Vue' }] }, {
{ id: 'q2', question: '后端?', options: [{ label: 'Go' }, { label: 'Rust' }] }, id: "q1",
]; question: "前端?",
const ans = answersFromDecision(qs, 'Vue\nGo', '都行'); options: [{ label: "React" }, { label: "Vue" }],
assert.deepEqual(ans.answers[0].selected, ['Vue'], 'q1 只拿前端的选择'); },
assert.deepEqual(ans.answers[1].selected, ['Go'], 'q2 只拿后端的选择'); {
assert.equal(ans.answers[0].custom, '都行', '备注归第一个问题'); id: "q2",
assert.equal(ans.answers[1].custom, undefined, '备注不重复分发'); question: "后端?",
options: [{ label: "Go" }, { label: "Rust" }],
},
];
const ans = answersFromDecision(qs, "Vue\nGo", "都行");
assert.deepEqual(ans.answers[0].selected, ["Vue"], "q1 只拿前端的选择");
assert.deepEqual(ans.answers[1].selected, ["Go"], "q2 只拿后端的选择");
assert.equal(ans.answers[0].custom, "都行", "备注归第一个问题");
assert.equal(ans.answers[1].custom, undefined, "备注不重复分发");
}); });
test('多问题里认不出的 label不匹配任何问题不猜测放行', () => { test("多问题里认不出的 label不匹配任何问题不猜测放行", () => {
const qs = [ const qs = [
{ id: 'q1', question: 'a', options: [{ label: 'X' }] }, { id: "q1", question: "a", options: [{ label: "X" }] },
{ id: 'q2', question: 'b', options: [{ label: 'Y' }] }, { id: "q2", question: "b", options: [{ label: "Y" }] },
]; ];
const ans = answersFromDecision(qs, 'Z', ''); const ans = answersFromDecision(qs, "Z", "");
assert.deepEqual(ans.answers[0].selected, [], '认不出的 label 不得被塞进任意问题'); assert.deepEqual(
assert.deepEqual(ans.answers[1].selected, []); ans.answers[0].selected,
[],
"认不出的 label 不得被塞进任意问题",
);
assert.deepEqual(ans.answers[1].selected, []);
}); });
test('answers 的 id 与问题一一对应', () => { test("answers 的 id 与问题一一对应", () => {
const qs = [{ id: 'alpha', question: 'a', options: [{ label: 'X' }] }, { id: 'beta', question: 'b' }]; const qs = [
const ans = answersFromDecision(qs, 'X', 'note'); { id: "alpha", question: "a", options: [{ label: "X" }] },
assert.deepEqual(ans.answers.map((a) => a.id), ['alpha', 'beta']); { id: "beta", question: "b" },
];
const ans = answersFromDecision(qs, "X", "note");
assert.deepEqual(
ans.answers.map((a) => a.id),
["alpha", "beta"],
);
}); });
test('空回答判定:有选项的问题什么都没选 = 空', () => { test("空回答判定:有选项的问题什么都没选 = 空", () => {
const qs = [{ id: 'q1', question: 'a', options: [{ label: 'X' }] }]; const qs = [{ id: "q1", question: "a", options: [{ label: "X" }] }];
assert.equal(isBlankAnswer(qs, '', ''), true); assert.equal(isBlankAnswer(qs, "", ""), true);
assert.equal(isBlankAnswer(qs, 'X', ''), false); assert.equal(isBlankAnswer(qs, "X", ""), false);
assert.equal(isBlankAnswer(qs, '', '自由文本'), false, '自由文本也算答了'); assert.equal(isBlankAnswer(qs, "", "自由文本"), false, "自由文本也算答了");
}); });
test('hasOptions / optionLabels / questionTitle 的边界', () => { test("hasOptions / optionLabels / questionTitle 的边界", () => {
assert.equal(hasOptions({}), false); assert.equal(hasOptions({}), false);
assert.equal(hasOptions({ options: [] }), false); assert.equal(hasOptions({ options: [] }), false);
assert.equal(hasOptions({ options: [{ label: 'a' }] }), true); assert.equal(hasOptions({ options: [{ label: "a" }] }), true);
assert.deepEqual(optionLabels({ options: ['a', { label: 'b' }, { description: 'x' }] }), ['a', 'b']); assert.deepEqual(
assert.equal(questionTitle({ question: '只问一句' }), '只问一句'); optionLabels({ options: ["a", { label: "b" }, { description: "x" }] }),
assert.equal(questionTitle({}), '(未提供问题)'); ["a", "b"],
);
assert.equal(questionTitle({ question: "只问一句" }), "只问一句");
assert.equal(questionTitle({}), "(未提供问题)");
}); });

View File

@ -0,0 +1,187 @@
/**
* pi 交互式会话的邮件工具扩展。
*
* # 与 src/index.mjs常驻守护进程的分工
*
* - 守护进程负责**收信**:订阅 SSE、fork worker、跑模型、自动回信
* - 本扩展只给**交互式 TUI 会话**装上同一套邮件工具
*
* 两者是同一条 AgentMail 身份agent `pi`)的两个入口,与 DSH 的
* 「TUI + 邮箱是同一个 Agent」完全一致。人可以在 TUI 里直接收发邮件,
* 也可以在邮箱里给 pi 发信 —— 两边看到的是同一条会话流。
*
* # 为什么必须单独做这个扩展
*
* 守护进程用 `noExtensions: true` 起会话(见 src/session-pool.mjs
* 所以它的邮件工具**不会**出现在人的交互式 pi 里。而平台的建设者与维护者
* 恰恰是在交互式 pi 里工作的:没有 send_mail / read_inbox他既看不到
* 自己刚发出的那封信,也无法回信,只能绕到 curl + 密钥直连 Gateway ——
* 一个「邮件驱动」的平台,维护者自己收不到邮件。
*
* # 密钥从哪来
*
* 交互式 pi 的进程环境里通常**没有** AGENTMAIL_* 变量(守护进程的
* EnvironmentFile 只注入给它自己)。因此按以下顺序解析:
*
* 1. 进程环境(`AGENTMAIL_AGENT_KEY` / `AGENTMAIL_GATEWAY_URL` / `AGENTMAIL_AGENT_NAME`
* 2. `AGENTMAIL_ENV_FILE`(默认 `/etc/agentmail/pi.env`)—— 部署时的权威来源,
* 与守护进程用的是同一把密钥,因此身份一致
* 3. `$AGENTMAIL_CONFIG_DIR/agent.key` 或 `~/.agentmail/agent.key`
* (插件的历史约定;接受 `key` 与 `key_token` 两种字段名)
*
* 找不到密钥时**不注册任何工具**并明确告知 —— 挂上一组永远 401 的工具比没有更糟:
* 模型会以为自己能发信,人却只看到一串认证失败。
*
* # 为什么不注册 connect_to_server
*
* 那个工具会重写 Gateway 坐标并重新登记密钥。在交互式会话里调用它会**动到守护
* 进程的配置**(两者共用同一把密钥与同一个 Agent 名),而守护进程才是真正长期
* 持有 SSE 长连的一方。坐标变更属于部署动作,不该由一次 TUI 对话触发。
*/
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
const DEFAULT_ENV_FILE = '/etc/agentmail/pi.env';
const DEFAULT_GATEWAY = 'http://127.0.0.1:8180';
/** 守护进程专用的生命周期工具:交互式会话里不注册(理由见文件头注释)。 */
const EXCLUDED_TOOLS = new Set(['connect_to_server']);
/**
* registerTool 期望的完整定义形状。
*
* 用 SDK 自己的类型取而不是复制一份SDK 升级时形状变化会在这里变成编译错误,
* 而不是运行时的静默错配。
*/
type MailTool = Parameters<ExtensionAPI['registerTool']>[0];
/** 极简 .env 解析:只认 `KEY=value`,忽略注释与空行,不处理引号与转义。 */
function parseEnvFile(path: string): Record<string, string> {
const out: Record<string, string> = {};
if (!existsSync(path)) return out;
let raw: string;
try {
raw = readFileSync(path, 'utf8');
} catch {
return out;
}
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
// 值里可能有 `=`(例如 base64只切第一个
out[key] = trimmed.slice(eq + 1).trim();
}
return out;
}
/** 读插件约定的本地密钥文件,兼容 `key` 与 `key_token` 两种字段名。 */
function readLocalKey(configDir?: string): string {
const candidates = [
join(configDir || join(homedir(), '.agentmail'), 'agent.key'),
join(homedir(), '.agentmail', 'agent.key'),
];
for (const path of candidates) {
try {
if (!existsSync(path)) continue;
const raw = JSON.parse(readFileSync(path, 'utf8')) as
{ key?: unknown; key_token?: unknown };
// 历史文件用的是 key_token见部署实况只认 `key` 会静默读不到。
const key = raw?.key ?? raw?.key_token;
if (typeof key === 'string' && key.trim()) return key.trim();
} catch {
// 读不了就试下一个
}
}
return '';
}
interface MailConfig {
url: string;
agentName: string;
agentKey: string;
}
/** @returns null 表示拿不到密钥(调用方据此不注册任何工具) */
function resolveConfig(): MailConfig | null {
let url = process.env.AGENTMAIL_GATEWAY_URL || '';
let agentName = process.env.AGENTMAIL_AGENT_NAME || '';
let agentKey = process.env.AGENTMAIL_AGENT_KEY || '';
if (!agentKey) {
const envFile = process.env.AGENTMAIL_ENV_FILE || DEFAULT_ENV_FILE;
const parsed = parseEnvFile(envFile);
agentKey = parsed.AGENTMAIL_AGENT_KEY || '';
url = url || parsed.AGENTMAIL_GATEWAY_URL || '';
agentName = agentName || parsed.AGENTMAIL_AGENT_NAME || '';
}
if (!agentKey) agentKey = readLocalKey(process.env.AGENTMAIL_CONFIG_DIR);
if (!agentKey) return null;
return {
url: (url || DEFAULT_GATEWAY).replace(/\/+$/, ''),
agentName: agentName || 'pi',
agentKey,
};
}
export default async function (pi: ExtensionAPI): Promise<void> {
const cfg = resolveConfig();
// 动态 import 仓库里的 .mjs它们没有 .d.ts也不该造一份 —— 守护进程用同一份
// 对象经 customTools 注册,额外声明只会两边不同步),因此在边界上收窄成我们
// 真正依赖的最小形状。工具对象的 `parameters` 是普通 JSON Schema
// registerTool 的类型要求 TypeBox 的 TSchema两者在运行期是同一套 JSON Schema
// 校验(守护进程已长期验证),差异只在静态类型上,故此处一次收窄并注明。
let registered = 0;
let notice = '';
if (!cfg) {
notice = 'pi-mail-bridge未找到 AgentMail 密钥AGENTMAIL_AGENT_KEY / '
+ `${process.env.AGENTMAIL_ENV_FILE || DEFAULT_ENV_FILE} / ~/.agentmail/agent.key`
+ '邮件工具未注册。';
} else {
const gatewayMod = await import(new URL('../src/gateway.mjs', import.meta.url).href);
const toolsMod = await import(new URL('../src/tools.mjs', import.meta.url).href);
const GatewayClient = gatewayMod.GatewayClient as new (opts: {
url: string; agentName: string; agentKey: string; agentSecret: string;
}) => unknown;
const createMailTools = toolsMod.createMailTools as (opts: {
client: unknown; agentName: string; log: (msg: string) => void;
}) => MailTool[];
const tools = createMailTools({
client: new GatewayClient({
url: cfg.url,
agentName: cfg.agentName,
agentKey: cfg.agentKey,
agentSecret: '',
}),
agentName: cfg.agentName,
// 出错只进 stderr不污染 TUI正常路径本身不吵闹。
log: (msg: string) => console.error(`[pi-mail-tools] ${msg}`),
});
for (const tool of tools) {
if (!tool?.name || EXCLUDED_TOOLS.has(tool.name)) continue;
pi.registerTool(tool);
registered += 1;
}
}
// 只在 session_start 里报:扩展工厂可能在「不会开会话」的调用里执行
// (例如 pi --list-models那里没有 ctx也没有界面可显示。
pi.on('session_start', async (_event, ctx) => {
if (notice) {
ctx.ui.notify(notice, 'warning');
return;
}
ctx.ui.setStatus('pi-mail', `邮件已接入(${cfg?.agentName}@${cfg?.url}${registered} 个工具)`);
});
}

View File

@ -5,16 +5,17 @@
* 本文件只管认证头、密钥解析与坐标变更。 * 本文件只管认证头、密钥解析与坐标变更。
*/ */
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { randomBytes } from 'node:crypto'; import { randomBytes } from "node:crypto";
import { homedir } from 'node:os'; import { homedir } from "node:os";
import { join } from 'node:path'; import { join } from "node:path";
import { createSSEClient } from '../lib/sse-client.js'; import { createSSEClient } from "../lib/sse-client.js";
const CONFIG_DIR = process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), '.agentmail'); const CONFIG_DIR =
export const KEY_FILE = join(CONFIG_DIR, 'agent.key'); process.env.AGENTMAIL_CONFIG_DIR || join(homedir(), ".agentmail");
const CONFIG_FILE = join(CONFIG_DIR, 'config.json'); export const KEY_FILE = join(CONFIG_DIR, "agent.key");
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
/** /**
* 把管理员给的密钥落盘0600 * 把管理员给的密钥落盘0600
@ -23,23 +24,29 @@ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
* 否则重启后又回到无法连接的状态 —— 而那正是这个工具要解决的问题。 * 否则重启后又回到无法连接的状态 —— 而那正是这个工具要解决的问题。
*/ */
export function saveLocalKey(token) { export function saveLocalKey(token) {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync( writeFileSync(
KEY_FILE, KEY_FILE,
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2), JSON.stringify(
{ mode: 0o600 }, { key_token: token, created_at: new Date().toISOString() },
); null,
2,
),
{ mode: 0o600 },
);
} }
/** 读取本地密钥文件;不存在或损坏时返回 null。 */ /** 读取本地密钥文件;不存在或损坏时返回 null。 */
export function readLocalKey() { export function readLocalKey() {
try { try {
if (!existsSync(KEY_FILE)) return null; if (!existsSync(KEY_FILE)) return null;
const raw = JSON.parse(readFileSync(KEY_FILE, 'utf8')); const raw = JSON.parse(readFileSync(KEY_FILE, "utf8"));
return typeof raw?.key_token === 'string' && raw.key_token ? raw.key_token : null; return typeof raw?.key_token === "string" && raw.key_token
} catch { ? raw.key_token
return null; : null;
} } catch {
return null;
}
} }
/** /**
@ -49,164 +56,187 @@ export function readLocalKey() {
* 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl契约 9.8)。 * 走 console.error 而不是任何结构化日志 —— 它一定进 journalctl契约 9.8)。
*/ */
export function generateLocalKey(log = console.error) { export function generateLocalKey(log = console.error) {
const token = randomBytes(32).toString('hex'); const token = randomBytes(32).toString("hex");
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
writeFileSync( writeFileSync(
KEY_FILE, KEY_FILE,
JSON.stringify({ key_token: token, created_at: new Date().toISOString() }, null, 2), JSON.stringify(
{ mode: 0o600 }, { key_token: token, created_at: new Date().toISOString() },
); null,
// 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加 2,
log(`已在 ${KEY_FILE} 生成本地密钥。`); ),
log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`); { mode: 0o600 },
log(` ${token}`); );
return token; // 调用方传进来的 log 已经带 [pi-mail-bridge] 前缀,这里不再自己加
log(`已在 ${KEY_FILE} 生成本地密钥。`);
log(`该密钥需管理员在 AgentMail 后台登记后才能接入:`);
log(` ${token}`);
return token;
} }
/** 把 gateway 地址与身份记到 config.json便于换机时人工核对。 */ /** 把 gateway 地址与身份记到 config.json便于换机时人工核对。 */
export function saveConfig(extra) { export function saveConfig(extra) {
try { try {
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
let cur = {}; let cur = {};
if (existsSync(CONFIG_FILE)) { if (existsSync(CONFIG_FILE)) {
try { cur = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { /* 损坏就重写 */ } try {
} cur = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), { mode: 0o600 }); } catch {
} catch (e) { /* 损坏就重写 */
console.error('[pi-mail-bridge] 写 config.json 失败:', e?.message || e); }
} }
writeFileSync(CONFIG_FILE, JSON.stringify({ ...cur, ...extra }, null, 2), {
mode: 0o600,
});
} catch (e) {
console.error("[pi-mail-bridge] 写 config.json 失败:", e?.message || e);
}
} }
export class GatewayClient { export class GatewayClient {
/** /**
* @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts * @param {{url: string, agentName: string, agentKey: string, agentSecret: string}} opts
*/ */
constructor({ url, agentName, agentKey, agentSecret }) { constructor({ url, agentName, agentKey, agentSecret }) {
this.baseURL = String(url || 'http://127.0.0.1:8180').replace(/\/+$/, ''); this.baseURL = String(url || "http://127.0.0.1:8180").replace(/\/+$/, "");
this.agentName = agentName; this.agentName = agentName;
this.agentKey = agentKey || ''; this.agentKey = agentKey || "";
this.agentSecret = agentSecret || ''; this.agentSecret = agentSecret || "";
this.sseClient = null; this.sseClient = null;
} }
/** 认证头:有密钥走 Bearer否则退回 name/secret。 */ /** 认证头:有密钥走 Bearer否则退回 name/secret。 */
authHeaders() { authHeaders() {
if (this.agentKey) { if (this.agentKey) {
return { Authorization: `Bearer ${this.agentKey}`, 'X-Agent-Name': this.agentName }; return {
} Authorization: `Bearer ${this.agentKey}`,
return { 'X-Agent-Name': this.agentName, 'X-Agent-Secret': this.agentSecret }; "X-Agent-Name": this.agentName,
} };
}
return {
"X-Agent-Name": this.agentName,
"X-Agent-Secret": this.agentSecret,
};
}
async get(path) { async get(path) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, { headers: this.authHeaders() }); const res = await fetch(`${this.baseURL}/api/v1${path}`, {
if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`); headers: this.authHeaders(),
return res.json(); });
} if (!res.ok) throw new Error(`GET ${path} 失败: HTTP ${res.status}`);
return res.json();
}
async post(path, body) { async post(path, body) {
const res = await fetch(`${this.baseURL}/api/v1${path}`, { const res = await fetch(`${this.baseURL}/api/v1${path}`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, headers: { "Content-Type": "application/json", ...this.authHeaders() },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
const err = new Error(data?.error || `POST ${path} 失败: HTTP ${res.status}`); const err = new Error(
err.status = res.status; data?.error || `POST ${path} 失败: HTTP ${res.status}`,
// 响应体也带上:服务端对 409 会给 detail/suggestion );
// 那些文字要原文转给模型(它据此决定换什么做法)。 err.status = res.status;
err.body = data; // 响应体也带上:服务端对 409 会给 detail/suggestion
throw err; // 那些文字要原文转给模型(它据此决定换什么做法)。
} err.body = data;
return data; throw err;
} }
return data;
}
/** 注册。workspaces 传 []B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */ /** 注册。workspaces 传 []B-1.2)—— 工作目录由每封邮件的 to_workspace 决定。 */
async register() { async register() {
return this.post('/agent/register', { return this.post("/agent/register", {
name: this.agentName, name: this.agentName,
secret: this.agentSecret || '', secret: this.agentSecret || "",
workspaces: [], workspaces: [],
platform: 'pi', platform: "pi",
}); });
} }
/** /**
* 上传附件。 * 上传附件。
* *
* 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")` * 必须走 multipart 的 `file` 字段:服务端是 `r.FormFile("file")`
* 且**不认 `X-Filename` 头**grep 过 handler/attachments.go没有这个分支 * 且**不认 `X-Filename` 头**grep 过 handler/attachments.go没有这个分支
* 直接 POST 二进制体会得到 400「缺少 file 字段」。 * 直接 POST 二进制体会得到 400「缺少 file 字段」。
* *
* 不手动设 Content-Type让 undici 按 FormData 自己生成 boundary。 * 不手动设 Content-Type让 undici 按 FormData 自己生成 boundary。
*/ */
async uploadFile(buf, filename) { async uploadFile(buf, filename) {
const form = new FormData(); const form = new FormData();
form.append('file', new Blob([buf]), filename); form.append("file", new Blob([buf]), filename);
const res = await fetch(`${this.baseURL}/api/v1/attachments`, { const res = await fetch(`${this.baseURL}/api/v1/attachments`, {
method: 'POST', method: "POST",
headers: this.authHeaders(), headers: this.authHeaders(),
body: form, body: form,
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`); if (!res.ok) throw new Error(data?.error || `上传失败: HTTP ${res.status}`);
return data.attachment; return data.attachment;
} }
async downloadFile(attachmentID) { async downloadFile(attachmentID) {
const res = await fetch(`${this.baseURL}/api/v1/attachments/${attachmentID}`, { const res = await fetch(
headers: this.authHeaders(), `${this.baseURL}/api/v1/attachments/${attachmentID}`,
}); {
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`); headers: this.authHeaders(),
return Buffer.from(await res.arrayBuffer()); },
} );
if (!res.ok) throw new Error(`下载失败: HTTP ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
/** /**
* 建立 SSE 长连并自动重连。 * 建立 SSE 长连并自动重连。
* *
* 实现委托给共用模块 `lib/sse-client.js`(三桥逐字节同源,由 * 实现委托给共用模块 `lib/sse-client.js`(三桥逐字节同源,由
* deploy/check-shared-libs.sh 校验)—— 那里把「跨 TCP 分片保帧状态」与 * deploy/check-shared-libs.sh 校验)—— 那里把「跨 TCP 分片保帧状态」与
* 「Last-Event-ID 断点续传」两件事写对了一次,不必每个平台各抄一遍。 * 「Last-Event-ID 断点续传」两件事写对了一次,不必每个平台各抄一遍。
* *
* 断线重连带 `Last-Event-ID`D-7.2):服务端有 per-agent 环形缓冲, * 断线重连带 `Last-Event-ID`D-7.2):服务端有 per-agent 环形缓冲,
* 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。 * 能把断连期间的事件回放出来 —— 否则那段时间的邮件只能等下次重启补拉。
* 首次连接**不带**N-11那会让服务端把缓冲区里的旧事件全回放一遍。 * 首次连接**不带**N-11那会让服务端把缓冲区里的旧事件全回放一遍。
*/ */
startSSE(onEvent, log = console.error) { startSSE(onEvent, log = console.error) {
this.sseClient?.stop?.(); this.sseClient?.stop?.();
this.sseClient = createSSEClient({ this.sseClient = createSSEClient({
authHeaders: () => this.authHeaders(), authHeaders: () => this.authHeaders(),
baseURL: this.baseURL, baseURL: this.baseURL,
path: '/api/v1/events/stream', path: "/api/v1/events/stream",
onEvent, onEvent,
log, log,
}); });
} }
/** /**
* 换 Gateway 地址或换密钥。 * 换 Gateway 地址或换密钥。
* *
* 守护进程不能靠重启来应用新配置 —— connect_to_server 是模型在**运行中** * 守护进程不能靠重启来应用新配置 —— connect_to_server 是模型在**运行中**
* 调的,它期望调完就能收信。所以这里除了改字段还要重置断点: * 调的,它期望调完就能收信。所以这里除了改字段还要重置断点:
* `lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿去问新 Gateway 会 * `lastEventID` 是**旧** Gateway 环形缓冲里的序号,拿去问新 Gateway 会
* 命中一段完全无关的历史(或直接被拒),得到的事件属于别人的会话。 * 命中一段完全无关的历史(或直接被拒),得到的事件属于别人的会话。
* *
* @returns {boolean} 是否真的变了(没变就不必重连 SSE省一次断流 * @returns {boolean} 是否真的变了(没变就不必重连 SSE省一次断流
*/ */
reconfigure({ url, agentKey }) { reconfigure({ url, agentKey }) {
const nextURL = url ? String(url).replace(/\/+$/, '') : this.baseURL; const nextURL = url ? String(url).replace(/\/+$/, "") : this.baseURL;
const nextKey = agentKey || this.agentKey; const nextKey = agentKey || this.agentKey;
const changed = nextURL !== this.baseURL || nextKey !== this.agentKey; const changed = nextURL !== this.baseURL || nextKey !== this.agentKey;
if (!changed) return false; if (!changed) return false;
if (nextURL !== this.baseURL) this.sseClient?.reset?.(); if (nextURL !== this.baseURL) this.sseClient?.reset?.();
this.baseURL = nextURL; this.baseURL = nextURL;
this.agentKey = nextKey; this.agentKey = nextKey;
return true; return true;
} }
stopSSE() { stopSSE() {
this.sseClient?.stop?.(); this.sseClient?.stop?.();
this.sseClient = null; this.sseClient = null;
} }
} }

View File

@ -56,12 +56,16 @@
* 一处只增不减的结构。 * 一处只增不减的结构。
*/ */
import { fork } from 'node:child_process'; import { fork } from "node:child_process";
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from "node:url";
import { BoundedMap, BoundedSet, MAX_TRACKED_SESSIONS } from '../lib/bounded.js'; import {
BoundedMap,
BoundedSet,
MAX_TRACKED_SESSIONS,
} from "../lib/bounded.js";
const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url)); const WORKER_PATH = fileURLToPath(new URL("./worker.mjs", import.meta.url));
/** /**
* @param {object} deps * @param {object} deps
@ -77,277 +81,328 @@ const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
* 让调度不变量(并发上限、同会话串行、硬超时)能在毫秒级验证。 * 让调度不变量(并发上限、同会话串行、硬超时)能在毫秒级验证。
*/ */
export function createWorkerPool({ export function createWorkerPool({
log, config, onReconfigure, log,
maxWorkers = 3, workerMaxMs = 600_000, maxAttempts = 3, workerPath = WORKER_PATH, config,
onReconfigure,
maxWorkers = 3,
workerMaxMs = 600_000,
maxAttempts = 3,
workerPath = WORKER_PATH,
}) { }) {
/** 正在跑的 workermailSessionKey -> {child, mailID, startedAt, timer} */ /** 正在跑的 workermailSessionKey -> {child, mailID, startedAt, timer} */
const running = new Map(); const running = new Map();
/** 等着派的活,先进先出。 */ /** 等着派的活,先进先出。 */
const queue = []; const queue = [];
/** relay_key -> mailSessionKey把决策路由回发起询问的那个 worker。 */ /** relay_key -> mailSessionKey把决策路由回发起询问的那个 worker。 */
const permissionRoutes = new Map(); const permissionRoutes = new Map();
/** /**
* 跨 worker 存活的会话状态mailSessionKey -> {sessionFile, cwd, piSessionId, * 跨 worker 存活的会话状态mailSessionKey -> {sessionFile, cwd, piSessionId,
* grants:Set, lastSyncedName}。 * grants:Set, lastSyncedName}。
* *
* 这是 worker 一封一进程之后仍需在主进程留存的全部东西 —— 下一封邮件靠 * 这是 worker 一封一进程之后仍需在主进程留存的全部东西 —— 下一封邮件靠
* sessionFile 接着谈,靠 grants 不重复问已经「一直同意」过的工具。 * sessionFile 接着谈,靠 grants 不重复问已经「一直同意」过的工具。
*/ */
const sessionState = new BoundedMap(MAX_TRACKED_SESSIONS); const sessionState = new BoundedMap(MAX_TRACKED_SESSIONS);
/** /**
* 被模型降级换掉的旧 pi 会话 id。 * 被模型降级换掉的旧 pi 会话 id。
* *
* 仍要计入 mail_driven它们已经参与过邮件往来而磁盘上的会话文件 * 仍要计入 mail_driven它们已经参与过邮件往来而磁盘上的会话文件
* 不会因为换模型而消失 —— 心跳快照仍会上报它们。 * 不会因为换模型而消失 —— 心跳快照仍会上报它们。
*/ */
const retired = new BoundedSet(MAX_TRACKED_SESSIONS); const retired = new BoundedSet(MAX_TRACKED_SESSIONS);
let stopped = false; let stopped = false;
/** /**
* 邮件会话 id 作为串行化的键。 * 邮件会话 id 作为串行化的键。
* *
* 没有 session_id 的事件(理论上不该有)退回 mail_id那样每封各占一个 * 没有 session_id 的事件(理论上不该有)退回 mail_id那样每封各占一个
* worker不会串行 —— 但它们本来也不属于同一条会话。 * worker不会串行 —— 但它们本来也不属于同一条会话。
*/ */
const keyOf = (data) => data?.session_id || `mail:${data?.mail_id || Math.random()}`; const keyOf = (data) =>
data?.session_id || `mail:${data?.mail_id || Math.random()}`;
function submit(kind, data, attempt = 1) { function submit(kind, data, attempt = 1) {
if (stopped) return; if (stopped) return;
queue.push({ kind, data, key: keyOf(data), attempt }); queue.push({ kind, data, key: keyOf(data), attempt });
pump(); pump();
} }
function pump() { function pump() {
if (stopped) return; if (stopped) return;
for (let i = 0; i < queue.length; i++) { for (let i = 0; i < queue.length; i++) {
const job = queue[i]; const job = queue[i];
// 同一会话已有 worker 在跑 → 跳过它,看后面有没有别的会话可以先跑。 // 同一会话已有 worker 在跑 → 跳过它,看后面有没有别的会话可以先跑。
// 不能 break那会让一条慢会话把所有别的会话都堵住正是要修的病 // 不能 break那会让一条慢会话把所有别的会话都堵住正是要修的病
if (running.has(job.key)) continue; if (running.has(job.key)) continue;
if (running.size >= maxWorkers) return; if (running.size >= maxWorkers) return;
queue.splice(i, 1); queue.splice(i, 1);
i--; i--;
spawn(job); spawn(job);
} }
} }
function spawn(job) { function spawn(job) {
const state = sessionState.get(job.key) || { grants: new Set(), lastSyncedName: '' }; const state = sessionState.get(job.key) || {
const child = fork(workerPath, [], { grants: new Set(),
// stdio 继承worker 里 pi SDK 自己打的东西直接进 journalctl。 lastSyncedName: "",
// 'ipc' 必须显式列出,否则 process.send 不存在。 };
stdio: ['ignore', 'inherit', 'inherit', 'ipc'], const child = fork(workerPath, [], {
}); // stdio 继承worker 里 pi SDK 自己打的东西直接进 journalctl。
// 'ipc' 必须显式列出,否则 process.send 不存在。
stdio: ["ignore", "inherit", "inherit", "ipc"],
});
// 硬超时worker 卡死(模型不返回、权限等不到决策而主进程也没收到事件) // 硬超时worker 卡死(模型不返回、权限等不到决策而主进程也没收到事件)
// 时必须能回收,否则那条会话的后续邮件永远排队。 // 时必须能回收,否则那条会话的后续邮件永远排队。
const timer = setTimeout(() => { const timer = setTimeout(() => {
log(`worker ${child.pid} 处理 ${job.data?.mail_id} 超过 ${workerMaxMs / 1000}s强杀`); log(
try { child.kill('SIGKILL'); } catch { /* 已经死了 */ } `worker ${child.pid} 处理 ${job.data?.mail_id} 超过 ${workerMaxMs / 1000}s强杀`,
}, workerMaxMs); );
if (typeof timer.unref === 'function') timer.unref(); try {
child.kill("SIGKILL");
} catch {
/* 已经死了 */
}
}, workerMaxMs);
if (typeof timer.unref === "function") timer.unref();
const entry = { const entry = {
child, mailID: job.data?.mail_id || '', key: job.key, child,
startedAt: Date.now(), timer, settled: false, mailID: job.data?.mail_id || "",
}; key: job.key,
running.set(job.key, entry); startedAt: Date.now(),
timer,
settled: false,
};
running.set(job.key, entry);
child.on('message', (msg) => onWorkerMessage(entry, msg)); child.on("message", (msg) => onWorkerMessage(entry, msg));
child.on('exit', (code, signal) => { child.on("exit", (code, signal) => {
clearTimeout(timer); clearTimeout(timer);
running.delete(job.key); running.delete(job.key);
for (const [rk, k] of permissionRoutes) if (k === job.key) permissionRoutes.delete(rk); for (const [rk, k] of permissionRoutes)
if (k === job.key) permissionRoutes.delete(rk);
// 没收到 `done` 就退出 = 这封邮件**从未处理完**。 // 没收到 `done` 就退出 = 这封邮件**从未处理完**。
// //
// 这是生产上真实存在的静默丢信路径worker 被 SIGKILL硬超时 // 这是生产上真实存在的静默丢信路径worker 被 SIGKILL硬超时
// OOM、或自己崩溃时`done` 永远不会到达,主进程只看到 exit code。 // OOM、或自己崩溃时`done` 永远不会到达,主进程只看到 exit code。
// 原来这里只记一行日志就 pump() —— 发件人看到信发出去了, // 原来这里只记一行日志就 pump() —— 发件人看到信发出去了,
// 而那条会话再也不会有人回。 // 而那条会话再也不会有人回。
// //
// 重投而不是直接由主进程回信worker 崩溃可能是内存/上游瞬时故障, // 重投而不是直接由主进程回信worker 崩溃可能是内存/上游瞬时故障,
// 重启一个进程真能跑通。有界maxAttempts是因为「必定失败」的邮件 // 重启一个进程真能跑通。有界maxAttempts是因为「必定失败」的邮件
// 无界重投会变成永久活锁,而日志里只有一行看不出是同一封在原地打转。 // 无界重投会变成永久活锁,而日志里只有一行看不出是同一封在原地打转。
if (!entry.settled && !stopped) { if (!entry.settled && !stopped) {
const attempt = job.attempt || 1; const attempt = job.attempt || 1;
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
const delay = attempt * 1000; const delay = attempt * 1000;
log(`worker ${child.pid}mail ${entry.mailID})未回报 done 就退出` log(
+ `code=${code} signal=${signal || '-'}${delay / 1000}s 后` `worker ${child.pid}mail ${entry.mailID})未回报 done 就退出` +
+ `${attempt + 1}/${maxAttempts} 次重投`); `code=${code} signal=${signal || "-"}${delay / 1000}s 后` +
const retry = setTimeout(() => { `${attempt + 1}/${maxAttempts} 次重投`,
if (stopped) return; );
queue.push({ ...job, attempt: attempt + 1 }); const retry = setTimeout(() => {
pump(); if (stopped) return;
}, delay); queue.push({ ...job, attempt: attempt + 1 });
if (typeof retry.unref === 'function') retry.unref(); pump();
// 退避期间不 pump否则同一会话会被立刻重投退避形同虚设 }, delay);
return; if (typeof retry.unref === "function") retry.unref();
} // 退避期间不 pump否则同一会话会被立刻重投退避形同虚设
log(`worker ${child.pid}mail ${entry.mailID})重投 ${maxAttempts} 次仍未完成,放弃` return;
+ `code=${code} signal=${signal || '-'}`); }
} else if (code !== 0) { log(
log(`worker ${child.pid}mail ${entry.mailID}异常退出 code=${code} signal=${signal || '-'}`); `worker ${child.pid}mail ${entry.mailID}重投 ${maxAttempts} 次仍未完成,放弃` +
} `code=${code} signal=${signal || "-"}`,
pump(); );
}); } else if (code !== 0) {
log(
`worker ${child.pid}mail ${entry.mailID})异常退出 code=${code} signal=${signal || "-"}`,
);
}
pump();
});
child.on('error', (e) => log(`worker ${child.pid} 出错: ${e?.message || e}`)); child.on("error", (e) =>
log(`worker ${child.pid} 出错: ${e?.message || e}`),
);
// 等 worker 说 ready 再派活fork 返回时子进程的 import 还没跑完, // 等 worker 说 ready 再派活fork 返回时子进程的 import 还没跑完,
// 此时 send 的消息会排在 IPC 队列里(能收到,但 ready 让顺序确定)。 // 此时 send 的消息会排在 IPC 队列里(能收到,但 ready 让顺序确定)。
child.once('message', function first(msg) { child.once("message", function first(msg) {
if (msg?.type !== 'ready') return; if (msg?.type !== "ready") return;
child.send({ child.send({
type: 'job', type: "job",
kind: job.kind, kind: job.kind,
data: job.data, data: job.data,
session: { session: {
sessionFile: state.sessionFile || '', sessionFile: state.sessionFile || "",
cwd: state.cwd || '', cwd: state.cwd || "",
}, },
grants: [...state.grants], grants: [...state.grants],
lastSyncedName: state.lastSyncedName || '', lastSyncedName: state.lastSyncedName || "",
config: config(), config: config(),
}); });
}); });
} }
function onWorkerMessage(entry, msg) { function onWorkerMessage(entry, msg) {
const state = sessionState.get(entry.key) || { grants: new Set(), lastSyncedName: '' }; const state = sessionState.get(entry.key) || {
switch (msg?.type) { grants: new Set(),
case 'log': lastSyncedName: "",
log(`[w${entry.child.pid}] ${msg.line}`); };
return; switch (msg?.type) {
case 'session_opened': case "log":
// 一条会话可能先后用过多个 pi 会话 id模型降级会换会话 log(`[w${entry.child.pid}] ${msg.line}`);
// 旧 id 仍计入 mail_driven理由见 retired 的注释。 return;
if (state.piSessionId && state.piSessionId !== msg.piSessionId) { case "session_opened":
retired.add(state.piSessionId); // 一条会话可能先后用过多个 pi 会话 id模型降级会换会话
} // 旧 id 仍计入 mail_driven理由见 retired 的注释。
state.piSessionId = msg.piSessionId; if (state.piSessionId && state.piSessionId !== msg.piSessionId) {
state.sessionFile = msg.sessionFile; retired.add(state.piSessionId);
state.cwd = msg.cwd; }
sessionState.set(entry.key, state); state.piSessionId = msg.piSessionId;
return; state.sessionFile = msg.sessionFile;
case 'permission_pending': state.cwd = msg.cwd;
permissionRoutes.set(msg.relayKey, entry.key); sessionState.set(entry.key, state);
return; return;
case 'permission_grant': case "permission_pending":
// 「一直同意」必须跨 worker 活着worker 一封一进程,不存的话下一封 permissionRoutes.set(msg.relayKey, entry.key);
// 邮件又问一遍,那个选项就是在骗人。 return;
state.grants.add(msg.toolName); case "permission_grant":
sessionState.set(entry.key, state); // 「一直同意」必须跨 worker 活着worker 一封一进程,不存的话下一封
return; // 邮件又问一遍,那个选项就是在骗人。
case 'name_synced': state.grants.add(msg.toolName);
state.lastSyncedName = msg.signature; sessionState.set(entry.key, state);
sessionState.set(entry.key, state); return;
return; case "name_synced":
case 'reconfigure': state.lastSyncedName = msg.signature;
onReconfigure?.(msg.url, msg.agentKey); sessionState.set(entry.key, state);
return; return;
case 'done': case "reconfigure":
// 标记「这封真的处理完了」exit 处理器据此区分「正常收尾」 onReconfigure?.(msg.url, msg.agentKey);
// 与「未回报就崩溃」(后者要重投)。 return;
entry.settled = true; case "done":
if (!msg.ok) log(`投递 ${entry.mailID} 失败: ${msg.error}`); // 标记「这封真的处理完了」exit 处理器据此区分「正常收尾」
return; // 与「未回报就崩溃」(后者要重投)。
default: entry.settled = true;
return; if (!msg.ok) log(`投递 ${entry.mailID} 失败: ${msg.error}`);
} return;
} default:
return;
}
}
/** /**
* 把权限决策路由到发起询问的那个 worker。 * 把权限决策路由到发起询问的那个 worker。
* *
* @returns {boolean} 有没有找到对应的 worker。找不到说明那个 worker 已经退了 * @returns {boolean} 有没有找到对应的 worker。找不到说明那个 worker 已经退了
* (桥重启、硬超时被杀、或者处理已经结束)—— 调用方据此走 B-4.2 的 * (桥重启、硬超时被杀、或者处理已经结束)—— 调用方据此走 B-4.2 的
* 降级路径(把决策当一封通知投进原会话)。 * 降级路径(把决策当一封通知投进原会话)。
*/ */
function routePermission(relayKey, decision) { function routePermission(relayKey, decision) {
const key = permissionRoutes.get(relayKey); const key = permissionRoutes.get(relayKey);
if (!key) return false; if (!key) return false;
const entry = running.get(key); const entry = running.get(key);
if (!entry) { if (!entry) {
permissionRoutes.delete(relayKey); permissionRoutes.delete(relayKey);
return false; return false;
} }
permissionRoutes.delete(relayKey); permissionRoutes.delete(relayKey);
entry.child.send({ type: 'permission_decision', relayKey, decision }); entry.child.send({ type: "permission_decision", relayKey, decision });
return true; return true;
} }
/** 这条邮件会话有 worker 在跑吗B-4.2 判断降级路径用)。 */ /** 这条邮件会话有 worker 在跑吗B-4.2 判断降级路径用)。 */
const hasSession = (mailSessionID) => sessionState.has(mailSessionID); const hasSession = (mailSessionID) => sessionState.has(mailSessionID);
/** /**
* 忘掉一条已归档会话的全部状态。 * 忘掉一条已归档会话的全部状态。
* *
* 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404也不会再有 * 归档是个**确定性的终点**:归档后那条会话不可寻址(别名 404也不会再有
* 新邮件投进来。把它的 sessionState 留着只是占内存,而上限淘汰是「猜」—— * 新邮件投进来。把它的 sessionState 留着只是占内存,而上限淘汰是「猜」——
* 能确切知道该删的时候就不该依赖猜。 * 能确切知道该删的时候就不该依赖猜。
* *
* 正在跑的 worker **不杀**:归档不是中止指令,模型可能正在写文件;它自己跑完 * 正在跑的 worker **不杀**:归档不是中止指令,模型可能正在写文件;它自己跑完
* 就退,只是那一轮的回信会因为会话已归档而被服务端拦下。 * 就退,只是那一轮的回信会因为会话已归档而被服务端拦下。
* *
* @param {string} mailSessionID * @param {string} mailSessionID
* @returns {boolean} 是否真的删掉了东西 * @returns {boolean} 是否真的删掉了东西
*/ */
function forget(mailSessionID) { function forget(mailSessionID) {
if (!mailSessionID) return false; if (!mailSessionID) return false;
// peek 而不是 get这是清理路径不该把即将删掉的条目刷成「最近活跃」。 // peek 而不是 get这是清理路径不该把即将删掉的条目刷成「最近活跃」。
const state = sessionState.peek(mailSessionID); const state = sessionState.peek(mailSessionID);
// 已归档会话的 pi 会话 id 也不必再报 mail_driven那个标记的用途是让人在 // 已归档会话的 pi 会话 id 也不必再报 mail_driven那个标记的用途是让人在
// 补全里看到「这条在跑邮件」,而已归档的会话不在补全候选里。 // 补全里看到「这条在跑邮件」,而已归档的会话不在补全候选里。
if (state?.piSessionId) retired.delete(state.piSessionId); if (state?.piSessionId) retired.delete(state.piSessionId);
return sessionState.delete(mailSessionID); return sessionState.delete(mailSessionID);
} }
/** /**
* 邮件驱动过的 pi 会话 id喂给心跳快照的 `mail_driven` 标记。 * 邮件驱动过的 pi 会话 id喂给心跳快照的 `mail_driven` 标记。
* *
* 不随 worker 退出而清worker 退了不代表那条会话不再参与邮件往来 —— * 不随 worker 退出而清worker 退了不代表那条会话不再参与邮件往来 ——
* 下一封邮件还会接着谈,而人在补全里需要看到它带着这个标记。 * 下一封邮件还会接着谈,而人在补全里需要看到它带着这个标记。
* 重启丢是已知取舍(契约第六节);确定性的清理时机是归档(见 forget * 重启丢是已知取舍(契约第六节);确定性的清理时机是归档(见 forget
* *
* 返回普通 Set 而不是 BoundedSet调用方只拿它做一轮 has 查询就丢, * 返回普通 Set 而不是 BoundedSet调用方只拿它做一轮 has 查询就丢,
* 没有长期持有,不需要上界。 * 没有长期持有,不需要上界。
*/ */
const mailDrivenIDs = () => { const mailDrivenIDs = () => {
const out = new Set(retired); const out = new Set(retired);
for (const st of sessionState.values()) { for (const st of sessionState.values()) {
if (st.piSessionId) out.add(st.piSessionId); if (st.piSessionId) out.add(st.piSessionId);
} }
return out; return out;
}; };
function stop() { function stop() {
stopped = true; stopped = true;
queue.length = 0; queue.length = 0;
for (const { child, timer } of running.values()) { for (const { child, timer } of running.values()) {
clearTimeout(timer); clearTimeout(timer);
// 先 shutdown 让 worker 把未决权限 fail closedB-9.2),再给它一点 // 先 shutdown 让 worker 把未决权限 fail closedB-9.2),再给它一点
// 时间自己退。不直接 SIGKILL那样 pi 侧的 await 不会返回,而 worker // 时间自己退。不直接 SIGKILL那样 pi 侧的 await 不会返回,而 worker
// 里可能正握着会话文件。 // 里可能正握着会话文件。
try { child.send({ type: 'shutdown' }); } catch { /* 通道已断 */ } try {
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* 已经死了 */ } }, 2000).unref?.(); child.send({ type: "shutdown" });
} } catch {
} /* 通道已断 */
}
setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
/* 已经死了 */
}
}, 2000).unref?.();
}
}
/** 观测用:现在跑着几个、排了几个。 */ /** 观测用:现在跑着几个、排了几个。 */
const stats = () => ({ const stats = () => ({
running: running.size, running: running.size,
queued: queue.length, queued: queue.length,
sessions: sessionState.size, sessions: sessionState.size,
// 淘汰计数持续增长说明上限设得太小 —— 那意味着会话上下文在被白白丢掉, // 淘汰计数持续增长说明上限设得太小 —— 那意味着会话上下文在被白白丢掉,
// 而症状是「这条会话怎么突然不记得前面说过什么了」。 // 而症状是「这条会话怎么突然不记得前面说过什么了」。
evictedSessions: sessionState.evicted, evictedSessions: sessionState.evicted,
workers: [...running.values()].map((e) => ({ workers: [...running.values()].map((e) => ({
pid: e.child.pid, mailID: e.mailID, ageMs: Date.now() - e.startedAt, pid: e.child.pid,
})), mailID: e.mailID,
}); ageMs: Date.now() - e.startedAt,
})),
});
return { submit, routePermission, hasSession, forget, mailDrivenIDs, stop, stats }; return {
submit,
routePermission,
hasSession,
forget,
mailDrivenIDs,
stop,
stats,
};
} }

View File

@ -11,19 +11,21 @@
* 而不是一个间接的计数(计数在字段被丢掉时依然会给出绿色)。 * 而不是一个间接的计数(计数在字段被丢掉时依然会给出绿色)。
*/ */
import { test } from 'node:test'; import { test } from "node:test";
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from 'node:fs'; import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from 'node:os'; import { tmpdir } from "node:os";
import { join } from 'node:path'; import { join } from "node:path";
// ─── 桩 worker ─── // ─── 桩 worker ───
// //
// 落到临时目录而不是仓库里:它是测试脚手架,不该被 check-shared-libs 之类的 // 落到临时目录而不是仓库里:它是测试脚手架,不该被 check-shared-libs 之类的
// 一致性脚本看到,也不该让人误以为是第二个真 worker。 // 一致性脚本看到,也不该让人误以为是第二个真 worker。
const STUB_DIR = mkdtempSync(join(tmpdir(), 'pi-pool-test-')); const STUB_DIR = mkdtempSync(join(tmpdir(), "pi-pool-test-"));
const STUB = join(STUB_DIR, 'stub-worker.mjs'); const STUB = join(STUB_DIR, "stub-worker.mjs");
writeFileSync(STUB, ` writeFileSync(
STUB,
`
process.on('message', (msg) => { process.on('message', (msg) => {
if (msg?.type === 'job') { if (msg?.type === 'job') {
const hold = msg.data?.__hold ?? 30; const hold = msg.data?.__hold ?? 30;
@ -80,297 +82,379 @@ process.on('message', (msg) => {
} }
}); });
process.send({ type: 'ready' }); process.send({ type: 'ready' });
`); `,
);
const { createWorkerPool } = await import('../src/pool.mjs'); const { createWorkerPool } = await import("../src/pool.mjs");
/** 建一个用桩 worker 的池。 */ /** 建一个用桩 worker 的池。 */
function makePool(opts = {}) { function makePool(opts = {}) {
const lines = []; const lines = [];
const pool = createWorkerPool({ const pool = createWorkerPool({
log: (...a) => lines.push(a.join(' ')), log: (...a) => lines.push(a.join(" ")),
config: () => ({ turnTimeoutMs: 1000, ...(opts.config || {}) }), config: () => ({ turnTimeoutMs: 1000, ...(opts.config || {}) }),
onReconfigure: opts.onReconfigure || (() => {}), onReconfigure: opts.onReconfigure || (() => {}),
maxWorkers: opts.maxWorkers ?? 2, maxWorkers: opts.maxWorkers ?? 2,
workerMaxMs: opts.workerMaxMs ?? 5000, workerMaxMs: opts.workerMaxMs ?? 5000,
maxAttempts: opts.maxAttempts, maxAttempts: opts.maxAttempts,
workerPath: opts.workerPath || STUB, workerPath: opts.workerPath || STUB,
}); });
return { pool, lines }; return { pool, lines };
} }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** 轮询到条件成立或超时 —— 比固定 sleep 稳。 */ /** 轮询到条件成立或超时 —— 比固定 sleep 稳。 */
async function until(fn, timeoutMs = 4000) { async function until(fn, timeoutMs = 4000) {
const t0 = Date.now(); const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) { while (Date.now() - t0 < timeoutMs) {
if (fn()) return true; if (fn()) return true;
await sleep(20); await sleep(20);
} }
return false; return false;
} }
/** 从日志行里取出桩 worker 回声的 job 载荷。 */ /** 从日志行里取出桩 worker 回声的 job 载荷。 */
function jobs(lines) { function jobs(lines) {
const out = []; const out = [];
for (const l of lines) { for (const l of lines) {
const i = l.indexOf('JOB '); const i = l.indexOf("JOB ");
if (i === -1) continue; if (i === -1) continue;
try { out.push(JSON.parse(l.slice(i + 4))); } catch { /* 不是完整一行 */ } try {
} out.push(JSON.parse(l.slice(i + 4)));
return out; } catch {
/* 不是完整一行 */
}
}
return out;
} }
/** 某个 mailID 的心跳出现过几次。 */ /** 某个 mailID 的心跳出现过几次。 */
const ticks = (lines, id) => lines.filter((l) => l.includes(`TICK ${id}`)).length; const ticks = (lines, id) =>
lines.filter((l) => l.includes(`TICK ${id}`)).length;
test('并发上限被遵守:第三条会话要等前面空出来', async () => { test("并发上限被遵守:第三条会话要等前面空出来", async () => {
const { pool } = makePool({ maxWorkers: 2 }); const { pool } = makePool({ maxWorkers: 2 });
for (const id of ['a', 'b', 'c']) { for (const id of ["a", "b", "c"]) {
pool.submit('mail', { mail_id: id, session_id: `S-${id}`, __hold: 250 }); pool.submit("mail", { mail_id: id, session_id: `S-${id}`, __hold: 250 });
} }
let peak = 0; let peak = 0;
const t = setInterval(() => { peak = Math.max(peak, pool.stats().running); }, 15); const t = setInterval(() => {
const sawQueue = await until(() => pool.stats().queued > 0, 1000); peak = Math.max(peak, pool.stats().running);
await until(() => pool.stats().running === 0 && pool.stats().queued === 0); }, 15);
clearInterval(t); const sawQueue = await until(() => pool.stats().queued > 0, 1000);
pool.stop(); await until(() => pool.stats().running === 0 && pool.stats().queued === 0);
clearInterval(t);
pool.stop();
assert.ok(peak <= 2, `同时跑的 worker 峰值 ${peak},不该超过 maxWorkers=2`); assert.ok(peak <= 2, `同时跑的 worker 峰值 ${peak},不该超过 maxWorkers=2`);
assert.ok(sawQueue, '满载时第三封该进队列而不是被丢掉'); assert.ok(sawQueue, "满载时第三封该进队列而不是被丢掉");
}); });
test('同一会话串行:两个 worker 的心跳不得重叠', async () => { test("同一会话串行:两个 worker 的心跳不得重叠", async () => {
const { pool, lines } = makePool({ maxWorkers: 3 }); const { pool, lines } = makePool({ maxWorkers: 3 });
pool.submit('mail', { mail_id: 'm1', session_id: 'SAME', __hold: 220 }); pool.submit("mail", { mail_id: "m1", session_id: "SAME", __hold: 220 });
pool.submit('mail', { mail_id: 'm2', session_id: 'SAME', __hold: 60 }); pool.submit("mail", { mail_id: "m2", session_id: "SAME", __hold: 60 });
// 判据一:任一时刻只有一个 worker 在跑。 // 判据一:任一时刻只有一个 worker 在跑。
let everTwo = false; let everTwo = false;
const t = setInterval(() => { if (pool.stats().running > 1) everTwo = true; }, 10); const t = setInterval(() => {
await until(() => jobs(lines).length === 2 && pool.stats().running === 0, 5000); if (pool.stats().running > 1) everTwo = true;
clearInterval(t); }, 10);
pool.stop(); await until(
() => jobs(lines).length === 2 && pool.stats().running === 0,
5000,
);
clearInterval(t);
pool.stop();
assert.equal(everTwo, false, '同一条会话不得有两个 worker 同时装载会话文件'); assert.equal(everTwo, false, "同一条会话不得有两个 worker 同时装载会话文件");
// 判据二m2 一次心跳都没能在 m1 结束前发出 —— m1 的心跳数应当远多于 m2。 // 判据二m2 一次心跳都没能在 m1 结束前发出 —— m1 的心跳数应当远多于 m2。
assert.ok(ticks(lines, 'm1') >= 3, `m1 该跑满 220ms实际心跳 ${ticks(lines, 'm1')}`); assert.ok(
assert.equal(jobs(lines).length, 2, '两封都要被处理,不能因为串行而丢掉一封'); ticks(lines, "m1") >= 3,
`m1 该跑满 220ms实际心跳 ${ticks(lines, "m1")}`,
);
assert.equal(jobs(lines).length, 2, "两封都要被处理,不能因为串行而丢掉一封");
}); });
test('不同会话真并发:两个进程的心跳在同一段时间里交错', async () => { test("不同会话真并发:两个进程的心跳在同一段时间里交错", async () => {
const { pool, lines } = makePool({ maxWorkers: 3 }); const { pool, lines } = makePool({ maxWorkers: 3 });
pool.submit('mail', { mail_id: 'p', session_id: 'S-P', __hold: 300 }); pool.submit("mail", { mail_id: "p", session_id: "S-P", __hold: 300 });
pool.submit('mail', { mail_id: 'q', session_id: 'S-Q', __hold: 300 }); pool.submit("mail", { mail_id: "q", session_id: "S-Q", __hold: 300 });
const interleaved = await until(() => ticks(lines, 'p') >= 2 && ticks(lines, 'q') >= 2, 2500); const interleaved = await until(
pool.stop(); () => ticks(lines, "p") >= 2 && ticks(lines, "q") >= 2,
assert.ok(interleaved, 2500,
`两条不同会话应当并发,实际 p=${ticks(lines, 'p')} q=${ticks(lines, 'q')} 次心跳`); );
pool.stop();
assert.ok(
interleaved,
`两条不同会话应当并发,实际 p=${ticks(lines, "p")} q=${ticks(lines, "q")} 次心跳`,
);
}); });
test('sessionFile 与 cwd 跨 worker 传下去:第二封接着第一封的会话谈', async () => { test("sessionFile 与 cwd 跨 worker 传下去:第二封接着第一封的会话谈", async () => {
const { pool, lines } = makePool({ maxWorkers: 2 }); const { pool, lines } = makePool({ maxWorkers: 2 });
pool.submit('mail', { mail_id: 'first', session_id: 'KEEP', __hold: 30 }); pool.submit("mail", { mail_id: "first", session_id: "KEEP", __hold: 30 });
await until(() => jobs(lines).length === 1 && pool.stats().running === 0); await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
pool.submit('mail', { mail_id: 'second', session_id: 'KEEP', __hold: 30 }); pool.submit("mail", { mail_id: "second", session_id: "KEEP", __hold: 30 });
await until(() => jobs(lines).length === 2 && pool.stats().running === 0); await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
pool.stop(); pool.stop();
const [j1, j2] = jobs(lines); const [j1, j2] = jobs(lines);
assert.equal(j1.sessionFile, '', '第一封时还没有会话文件'); assert.equal(j1.sessionFile, "", "第一封时还没有会话文件");
assert.equal(j2.sessionFile, '/tmp/f-first.jsonl', assert.equal(
'第二封必须带上第一封开出来的会话文件,否则每封邮件都从零开始、上下文全丢'); j2.sessionFile,
assert.equal(j2.cwd, '/tmp', 'cwd 也要传下去'); "/tmp/f-first.jsonl",
"第二封必须带上第一封开出来的会话文件,否则每封邮件都从零开始、上下文全丢",
);
assert.equal(j2.cwd, "/tmp", "cwd 也要传下去");
}); });
test('「一直同意」与命名指纹跨 worker 存活', async () => { test("「一直同意」与命名指纹跨 worker 存活", async () => {
const { pool, lines } = makePool({ maxWorkers: 2 }); const { pool, lines } = makePool({ maxWorkers: 2 });
pool.submit('mail', { pool.submit("mail", {
mail_id: 'g1', session_id: 'GRANT', __hold: 30, mail_id: "g1",
__grant: 'bash', __name: 'platform:某名字|某名字', session_id: "GRANT",
}); __hold: 30,
await until(() => jobs(lines).length === 1 && pool.stats().running === 0); __grant: "bash",
__name: "platform:某名字|某名字",
});
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
pool.submit('mail', { mail_id: 'g2', session_id: 'GRANT', __hold: 30 }); pool.submit("mail", { mail_id: "g2", session_id: "GRANT", __hold: 30 });
await until(() => jobs(lines).length === 2 && pool.stats().running === 0); await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
pool.stop(); pool.stop();
const [j1, j2] = jobs(lines); const [j1, j2] = jobs(lines);
assert.deepEqual(j1.grants, [], '第一封时还没人点过「一直同意」'); assert.deepEqual(j1.grants, [], "第一封时还没人点过「一直同意」");
assert.deepEqual(j2.grants, ['bash'], assert.deepEqual(
'「一直同意」不跨 worker 存活的话,下一封邮件又问一遍 —— 那个选项就是在骗人'); j2.grants,
assert.equal(j2.lastSyncedName, 'platform:某名字|某名字', ["bash"],
'命名指纹要传下去,否则每封邮件都重新 sync 一次'); "「一直同意」不跨 worker 存活的话,下一封邮件又问一遍 —— 那个选项就是在骗人",
);
assert.equal(
j2.lastSyncedName,
"platform:某名字|某名字",
"命名指纹要传下去,否则每封邮件都重新 sync 一次",
);
}); });
test('config() 每次派活时重取allowedModels 随心跳变,不能用快照', async () => { test("config() 每次派活时重取allowedModels 随心跳变,不能用快照", async () => {
let turnTimeoutMs = 111; let turnTimeoutMs = 111;
const { pool, lines } = makePool({ maxWorkers: 1, config: {} }); const { pool, lines } = makePool({ maxWorkers: 1, config: {} });
// makePool 的 config 是固定值,这里换成动态的 // makePool 的 config 是固定值,这里换成动态的
pool.stop(); pool.stop();
const lines2 = []; const lines2 = [];
const p2 = createWorkerPool({ const p2 = createWorkerPool({
log: (...a) => lines2.push(a.join(' ')), log: (...a) => lines2.push(a.join(" ")),
config: () => ({ turnTimeoutMs }), config: () => ({ turnTimeoutMs }),
onReconfigure: () => {}, onReconfigure: () => {},
maxWorkers: 1, maxWorkers: 1,
workerMaxMs: 5000, workerMaxMs: 5000,
workerPath: STUB, workerPath: STUB,
}); });
p2.submit('mail', { mail_id: 'c1', session_id: 'C1', __hold: 20 }); p2.submit("mail", { mail_id: "c1", session_id: "C1", __hold: 20 });
await until(() => jobs(lines2).length === 1 && p2.stats().running === 0); await until(() => jobs(lines2).length === 1 && p2.stats().running === 0);
turnTimeoutMs = 222; turnTimeoutMs = 222;
p2.submit('mail', { mail_id: 'c2', session_id: 'C2', __hold: 20 }); p2.submit("mail", { mail_id: "c2", session_id: "C2", __hold: 20 });
await until(() => jobs(lines2).length === 2 && p2.stats().running === 0); await until(() => jobs(lines2).length === 2 && p2.stats().running === 0);
p2.stop(); p2.stop();
const [j1, j2] = jobs(lines2); const [j1, j2] = jobs(lines2);
assert.equal(j1.turnTimeoutMs, 111); assert.equal(j1.turnTimeoutMs, 111);
assert.equal(j2.turnTimeoutMs, 222, 'config() 必须每次重取,否则 worker 用的是上一轮的模型范围'); assert.equal(
assert.equal(lines.length >= 0, true); j2.turnTimeoutMs,
222,
"config() 必须每次重取,否则 worker 用的是上一轮的模型范围",
);
assert.equal(lines.length >= 0, true);
}); });
test('硬超时回收卡死的 worker且不堵住同会话后续邮件', async () => { test("硬超时回收卡死的 worker且不堵住同会话后续邮件", async () => {
const { pool, lines } = makePool({ maxWorkers: 2, workerMaxMs: 400 }); const { pool, lines } = makePool({ maxWorkers: 2, workerMaxMs: 400 });
pool.submit('mail', { mail_id: 'stuck', session_id: 'STUCK', __hold: -1 }); pool.submit("mail", { mail_id: "stuck", session_id: "STUCK", __hold: -1 });
await until(() => pool.stats().running === 1, 1500); await until(() => pool.stats().running === 1, 1500);
const freed = await until(() => pool.stats().running === 0, 3000); const freed = await until(() => pool.stats().running === 0, 3000);
assert.ok(freed, '卡死的 worker 必须被硬超时回收,否则那条会话的后续邮件永远排队'); assert.ok(
assert.ok(lines.some((l) => l.includes('强杀')), `应记下强杀日志,实际:\n${lines.join('\n')}`); freed,
"卡死的 worker 必须被硬超时回收,否则那条会话的后续邮件永远排队",
);
assert.ok(
lines.some((l) => l.includes("强杀")),
`应记下强杀日志,实际:\n${lines.join("\n")}`,
);
pool.submit('mail', { mail_id: 'after', session_id: 'STUCK', __hold: 30 }); pool.submit("mail", { mail_id: "after", session_id: "STUCK", __hold: 30 });
const ran = await until(() => jobs(lines).some((j) => j.mailID === 'after'), 2000); const ran = await until(
await until(() => pool.stats().running === 0); () => jobs(lines).some((j) => j.mailID === "after"),
pool.stop(); 2000,
assert.ok(ran, '硬超时后同一会话的后续邮件必须能被处理'); );
await until(() => pool.stats().running === 0);
pool.stop();
assert.ok(ran, "硬超时后同一会话的后续邮件必须能被处理");
}); });
test('权限决策路由到发起询问的那个 worker', async () => { test("权限决策路由到发起询问的那个 worker", async () => {
const { pool, lines } = makePool({ maxWorkers: 2 }); const { pool, lines } = makePool({ maxWorkers: 2 });
pool.submit('mail', { mail_id: 'perm', session_id: 'PERM', __pending: 'rk-1' }); pool.submit("mail", {
await until(() => pool.stats().running === 1, 1500); mail_id: "perm",
await sleep(150); // 等 permission_pending 到主进程 session_id: "PERM",
__pending: "rk-1",
});
await until(() => pool.stats().running === 1, 1500);
await sleep(150); // 等 permission_pending 到主进程
assert.equal(pool.routePermission('rk-1', '同意'), true, '应当路由成功'); assert.equal(pool.routePermission("rk-1", "同意"), true, "应当路由成功");
await until(() => pool.stats().running === 0, 2000); await until(() => pool.stats().running === 0, 2000);
pool.stop(); pool.stop();
assert.ok(lines.some((l) => l.includes('DECISION rk-1=同意')), assert.ok(
`worker 应收到决策原文,实际:\n${lines.join('\n')}`); lines.some((l) => l.includes("DECISION rk-1=同意")),
`worker 应收到决策原文,实际:\n${lines.join("\n")}`,
);
}); });
test('决策发的是选项原文而不是归一化的 allow/deny', async () => { test("决策发的是选项原文而不是归一化的 allow/deny", async () => {
const { pool, lines } = makePool({ maxWorkers: 2 }); const { pool, lines } = makePool({ maxWorkers: 2 });
pool.submit('mail', { mail_id: 'p2', session_id: 'P2', __pending: 'rk-2' }); pool.submit("mail", { mail_id: "p2", session_id: "P2", __pending: "rk-2" });
await until(() => pool.stats().running === 1, 1500); await until(() => pool.stats().running === 1, 1500);
await sleep(150); await sleep(150);
pool.routePermission('rk-2', '一直同意'); pool.routePermission("rk-2", "一直同意");
await until(() => pool.stats().running === 0, 2000); await until(() => pool.stats().running === 0, 2000);
pool.stop(); pool.stop();
// 「同意」与「一直同意」语义不同,归一化会让后者退化成单次授权 // 「同意」与「一直同意」语义不同,归一化会让后者退化成单次授权
assert.ok(lines.some((l) => l.includes('DECISION rk-2=一直同意')), assert.ok(
`必须原文透传,实际:\n${lines.join('\n')}`); lines.some((l) => l.includes("DECISION rk-2=一直同意")),
`必须原文透传,实际:\n${lines.join("\n")}`,
);
}); });
test('决策找不到 worker 时返回 false调用方据此走 B-4.2 降级)', async () => { test("决策找不到 worker 时返回 false调用方据此走 B-4.2 降级)", async () => {
const { pool } = makePool(); const { pool } = makePool();
assert.equal(pool.routePermission('never-seen', '同意'), false); assert.equal(pool.routePermission("never-seen", "同意"), false);
pool.stop(); pool.stop();
}); });
test('worker 退出后它的权限路由被清掉,不会误投给下一个 worker', async () => { test("worker 退出后它的权限路由被清掉,不会误投给下一个 worker", async () => {
const { pool } = makePool({ maxWorkers: 2 }); const { pool } = makePool({ maxWorkers: 2 });
pool.submit('mail', { mail_id: 'gone', session_id: 'GONE', __pending: 'rk-gone' }); pool.submit("mail", {
await until(() => pool.stats().running === 1, 1500); mail_id: "gone",
await sleep(150); session_id: "GONE",
// 不给决策,直接停掉它 __pending: "rk-gone",
pool.stop(); });
await until(() => pool.stats().running === 0, 4000); await until(() => pool.stats().running === 1, 1500);
await sleep(150);
// 不给决策,直接停掉它
pool.stop();
await until(() => pool.stats().running === 0, 4000);
assert.equal(pool.routePermission('rk-gone', '同意'), false, assert.equal(
'worker 已退出,路由必须返回 false 让调用方走降级路径'); pool.routePermission("rk-gone", "同意"),
false,
"worker 已退出,路由必须返回 false 让调用方走降级路径",
);
}); });
test('mailDrivenIDs 报出所有跑过的 pi 会话,且不随 worker 退出而清', async () => { test("mailDrivenIDs 报出所有跑过的 pi 会话,且不随 worker 退出而清", async () => {
const { pool, lines } = makePool({ maxWorkers: 2 }); const { pool, lines } = makePool({ maxWorkers: 2 });
pool.submit('mail', { mail_id: 'd1', session_id: 'D1', __hold: 30 }); pool.submit("mail", { mail_id: "d1", session_id: "D1", __hold: 30 });
pool.submit('mail', { mail_id: 'd2', session_id: 'D2', __hold: 30 }); pool.submit("mail", { mail_id: "d2", session_id: "D2", __hold: 30 });
await until(() => jobs(lines).length === 2 && pool.stats().running === 0); await until(() => jobs(lines).length === 2 && pool.stats().running === 0);
pool.stop(); pool.stop();
const ids = pool.mailDrivenIDs(); const ids = pool.mailDrivenIDs();
assert.ok(ids.has('pi-d1'), 'D1 的 pi 会话该被标记为邮件驱动'); assert.ok(ids.has("pi-d1"), "D1 的 pi 会话该被标记为邮件驱动");
assert.ok(ids.has('pi-d2'), 'D2 的 pi 会话该被标记为邮件驱动'); assert.ok(ids.has("pi-d2"), "D2 的 pi 会话该被标记为邮件驱动");
}); });
test('模型降级换掉的旧 pi 会话仍算邮件驱动', async () => { test("模型降级换掉的旧 pi 会话仍算邮件驱动", async () => {
const { pool, lines } = makePool({ maxWorkers: 2 }); const { pool, lines } = makePool({ maxWorkers: 2 });
pool.submit('mail', { mail_id: 'r', session_id: 'RETIRE', __hold: 40, __reopen: 'pi-new' }); pool.submit("mail", {
await until(() => jobs(lines).length === 1 && pool.stats().running === 0); mail_id: "r",
pool.stop(); session_id: "RETIRE",
__hold: 40,
__reopen: "pi-new",
});
await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
pool.stop();
const ids = pool.mailDrivenIDs(); const ids = pool.mailDrivenIDs();
assert.ok(ids.has('pi-new'), '新会话要在'); assert.ok(ids.has("pi-new"), "新会话要在");
assert.ok(ids.has('pi-r'), '被换掉的旧会话也参与过邮件往来,磁盘上的文件还在,快照该报它'); assert.ok(
ids.has("pi-r"),
"被换掉的旧会话也参与过邮件往来,磁盘上的文件还在,快照该报它",
);
}); });
test('hasSession 只对跑过的邮件会话为真', async () => { test("hasSession 只对跑过的邮件会话为真", async () => {
const { pool } = makePool(); const { pool } = makePool();
assert.equal(pool.hasSession('NOPE'), false); assert.equal(pool.hasSession("NOPE"), false);
pool.submit('mail', { mail_id: 'h1', session_id: 'HAS', __hold: 30 }); pool.submit("mail", { mail_id: "h1", session_id: "HAS", __hold: 30 });
await until(() => pool.hasSession('HAS'), 2000); await until(() => pool.hasSession("HAS"), 2000);
await until(() => pool.stats().running === 0); await until(() => pool.stats().running === 0);
pool.stop(); pool.stop();
assert.equal(pool.hasSession('HAS'), true, 'worker 退出后仍该记着这条会话'); assert.equal(pool.hasSession("HAS"), true, "worker 退出后仍该记着这条会话");
}); });
test('kind 透传:权限通知走 permission 而不是 mail', async () => { test("kind 透传:权限通知走 permission 而不是 mail", async () => {
const { pool, lines } = makePool({ maxWorkers: 2 }); const { pool, lines } = makePool({ maxWorkers: 2 });
pool.submit('permission', { mail_id: 'k1', session_id: 'K1', __hold: 20 }); pool.submit("permission", { mail_id: "k1", session_id: "K1", __hold: 20 });
await until(() => jobs(lines).length === 1 && pool.stats().running === 0); await until(() => jobs(lines).length === 1 && pool.stats().running === 0);
pool.stop(); pool.stop();
assert.equal(jobs(lines)[0].kind, 'permission', assert.equal(
'kind 决定 worker 用哪套提示词,传错会让模型以为收到一封新邮件'); jobs(lines)[0].kind,
"permission",
"kind 决定 worker 用哪套提示词,传错会让模型以为收到一封新邮件",
);
}); });
test('stop 之后不再派活', async () => { test("stop 之后不再派活", async () => {
const { pool } = makePool(); const { pool } = makePool();
pool.stop(); pool.stop();
pool.submit('mail', { mail_id: 'late', session_id: 'LATE', __hold: 30 }); pool.submit("mail", { mail_id: "late", session_id: "LATE", __hold: 30 });
await sleep(200); await sleep(200);
assert.equal(pool.stats().running, 0, '关停后不该再起 worker'); assert.equal(pool.stats().running, 0, "关停后不该再起 worker");
assert.equal(pool.stats().queued, 0, '关停后队列应为空'); assert.equal(pool.stats().queued, 0, "关停后队列应为空");
}); });
test('stop 会先给 worker 发 shutdown让它 fail closed再杀', async () => { test("stop 会先给 worker 发 shutdown让它 fail closed再杀", async () => {
const { pool, lines } = makePool({ maxWorkers: 2 }); const { pool, lines } = makePool({ maxWorkers: 2 });
pool.submit('mail', { mail_id: 's1', session_id: 'S1', __hold: -1 }); pool.submit("mail", { mail_id: "s1", session_id: "S1", __hold: -1 });
await until(() => pool.stats().running === 1, 1500); await until(() => pool.stats().running === 1, 1500);
pool.stop(); pool.stop();
const gotShutdown = await until(() => lines.some((l) => l.includes('SHUTDOWN')), 2000); const gotShutdown = await until(
assert.ok(gotShutdown, () => lines.some((l) => l.includes("SHUTDOWN")),
'必须先发 shutdown直接 SIGKILL 会让 pi 侧那些等权限的 await 永不返回'); 2000,
);
assert.ok(
gotShutdown,
"必须先发 shutdown直接 SIGKILL 会让 pi 侧那些等权限的 await 永不返回",
);
}); });
test('没有 session_id 的事件各占一个 key不会互相串行', async () => { test("没有 session_id 的事件各占一个 key不会互相串行", async () => {
const { pool, lines } = makePool({ maxWorkers: 3 }); const { pool, lines } = makePool({ maxWorkers: 3 });
pool.submit('mail', { mail_id: 'n1', __hold: 300 }); pool.submit("mail", { mail_id: "n1", __hold: 300 });
pool.submit('mail', { mail_id: 'n2', __hold: 300 }); pool.submit("mail", { mail_id: "n2", __hold: 300 });
const both = await until(() => ticks(lines, 'n1') >= 2 && ticks(lines, 'n2') >= 2, 2500); const both = await until(
pool.stop(); () => ticks(lines, "n1") >= 2 && ticks(lines, "n2") >= 2,
assert.ok(both, '无 session_id 的两封不属于同一条会话,应能并发'); 2500,
);
pool.stop();
assert.ok(both, "无 session_id 的两封不属于同一条会话,应能并发");
}); });
test('reconfigure 上报被转达给主进程', async () => { test("reconfigure 上报被转达给主进程", async () => {
const STUB2 = join(STUB_DIR, 'stub-reconf.mjs'); const STUB2 = join(STUB_DIR, "stub-reconf.mjs");
writeFileSync(STUB2, ` writeFileSync(
STUB2,
`
process.on('message', (msg) => { process.on('message', (msg) => {
if (msg?.type === 'job') { if (msg?.type === 'job') {
process.send({ type: 'reconfigure', url: 'http://new:9999', agentKey: 'k2' }); process.send({ type: 'reconfigure', url: 'http://new:9999', agentKey: 'k2' });
@ -379,42 +463,57 @@ process.on('message', (msg) => {
} }
}); });
process.send({ type: 'ready' }); process.send({ type: 'ready' });
`); `,
let got = null; );
const { pool } = makePool({ let got = null;
maxWorkers: 1, const { pool } = makePool({
workerPath: STUB2, maxWorkers: 1,
onReconfigure: (url, key) => { got = { url, key }; }, workerPath: STUB2,
}); onReconfigure: (url, key) => {
pool.submit('mail', { mail_id: 'r1', session_id: 'R1' }); got = { url, key };
await until(() => got !== null, 3000); },
pool.stop(); });
assert.deepEqual(got, { url: 'http://new:9999', key: 'k2' }, pool.submit("mail", { mail_id: "r1", session_id: "R1" });
'worker 里 connect_to_server 换的坐标必须回到主进程 —— worker 马上就退了,改在它自己身上等于没改'); await until(() => got !== null, 3000);
pool.stop();
assert.deepEqual(
got,
{ url: "http://new:9999", key: "k2" },
"worker 里 connect_to_server 换的坐标必须回到主进程 —— worker 马上就退了,改在它自己身上等于没改",
);
}); });
test('worker 未回报 done 就退出:有界重投而不是静默丢信', async () => { test("worker 未回报 done 就退出:有界重投而不是静默丢信", async () => {
// maxAttempts=2首次 + 一次重投,然后放弃。 // maxAttempts=2首次 + 一次重投,然后放弃。
// 这封邮件必定崩溃,重投就是在验证「有界」——不然它会变成永久活锁。 // 这封邮件必定崩溃,重投就是在验证「有界」——不然它会变成永久活锁。
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 2 }); const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 2 });
pool.submit('mail', { mail_id: 'crashy', session_id: 'CRASH', __crash: true }); pool.submit("mail", {
mail_id: "crashy",
session_id: "CRASH",
__crash: true,
});
const gaveUp = await until(() => lines.some((l) => l.includes('放弃')), 6000); const gaveUp = await until(() => lines.some((l) => l.includes("放弃")), 6000);
pool.stop(); pool.stop();
assert.ok(gaveUp, `重投到上限后应记下「放弃」,实际:\n${lines.join('\n')}`); assert.ok(gaveUp, `重投到上限后应记下「放弃」,实际:\n${lines.join("\n")}`);
assert.equal(jobs(lines).length, 2, assert.equal(
`应当尝试 2 次(首次 + 1 次重投),实际 ${jobs(lines).length}`); jobs(lines).length,
assert.ok(lines.some((l) => l.includes('未回报 done 就退出')), 2,
'必须明说是「未回报 done 就退出」——否则看到 exit code 会误以为是普通崩溃'); `应当尝试 2 次(首次 + 1 次重投),实际 ${jobs(lines).length}`,
);
assert.ok(
lines.some((l) => l.includes("未回报 done 就退出")),
"必须明说是「未回报 done 就退出」——否则看到 exit code 会误以为是普通崩溃",
);
}); });
test('重投上限之下不会无限重投maxAttempts=1 就是不重投)', async () => { test("重投上限之下不会无限重投maxAttempts=1 就是不重投)", async () => {
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 1 }); const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 1 });
pool.submit('mail', { mail_id: 'once', session_id: 'ONCE', __crash: true }); pool.submit("mail", { mail_id: "once", session_id: "ONCE", __crash: true });
await until(() => lines.some((l) => l.includes('放弃')), 4000); await until(() => lines.some((l) => l.includes("放弃")), 4000);
await sleep(300); // 再等一会儿,确认没有额外重投 await sleep(300); // 再等一会儿,确认没有额外重投
pool.stop(); pool.stop();
assert.equal(jobs(lines).length, 1, 'maxAttempts=1 时只跑一次'); assert.equal(jobs(lines).length, 1, "maxAttempts=1 时只跑一次");
}); });

View File

@ -9,121 +9,132 @@
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。 * 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
*/ */
import { test } from 'node:test'; import { test } from "node:test";
import assert from 'node:assert/strict'; import assert from "node:assert/strict";
import { createFrameParser } from '../lib/sse-client.js'; import { createFrameParser } from "../lib/sse-client.js";
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */ /** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
function parse(s) { function parse(s) {
try { try {
return JSON.parse(s); return JSON.parse(s);
} catch (e) { } catch (e) {
assert.fail(`不是合法 JSON: ${s}${e.message}`); assert.fail(`不是合法 JSON: ${s}${e.message}`);
} }
} }
test('完整帧一次喂入:正常解析', () => { test("完整帧一次喂入:正常解析", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n'); const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
assert.equal(events.length, 1); assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail'); assert.equal(events[0].event, "new_mail");
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' }); assert.deepEqual(parse(events[0].data), { mail_id: "m1" });
assert.equal(events[0].id, '7'); assert.equal(events[0].id, "7");
assert.equal(p.lastEventId(), '7'); assert.equal(p.lastEventId(), "7");
}); });
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => { test("帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)", () => {
const p = createFrameParser(); const p = createFrameParser();
// chunk1 恰好停在 event 行之后、data 行之前 // chunk1 恰好停在 event 行之后、data 行之前
const first = p.push('id: 12\nevent: content_delta\n'); const first = p.push("id: 12\nevent: content_delta\n");
assert.deepEqual(first, [], '半帧不该派发'); assert.deepEqual(first, [], "半帧不该派发");
const second = p.push('data: {"x":1}\n\n'); const second = p.push('data: {"x":1}\n\n');
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃'); assert.equal(
assert.equal(second[0].event, 'content_delta'); second.length,
assert.equal(p.lastEventId(), '12'); 1,
"跨 chunk 的半帧必须被拼回完整事件,而不是丢弃",
);
assert.equal(second[0].event, "content_delta");
assert.equal(p.lastEventId(), "12");
}); });
test('帧被切在行中间buffer 保留半行', () => { test("帧被切在行中间buffer 保留半行", () => {
const p = createFrameParser(); const p = createFrameParser();
const a = p.push('event: new_ma'); const a = p.push("event: new_ma");
assert.deepEqual(a, []); assert.deepEqual(a, []);
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n'); const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
assert.equal(b.length, 1); assert.equal(b.length, 1);
assert.equal(b[0].event, 'new_mail'); assert.equal(b[0].event, "new_mail");
}); });
test('一个 chunk 里多帧连续:全部派发', () => { test("一个 chunk 里多帧连续:全部派发", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push( const events = p.push(
'event: new_mail\ndata: {"n":1}\n\n' + 'event: new_mail\ndata: {"n":1}\n\n' +
'event: new_mail\ndata: {"n":2}\n\n' + 'event: new_mail\ndata: {"n":2}\n\n' +
'event: session_update\ndata: {"n":3}\n\n' 'event: session_update\ndata: {"n":3}\n\n',
); );
assert.equal(events.length, 3); assert.equal(events.length, 3);
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']); assert.deepEqual(
events.map((e) => e.event),
["new_mail", "new_mail", "session_update"],
);
}); });
test('注释/心跳行被忽略,不影响后续帧', () => { test("注释/心跳行被忽略,不影响后续帧", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n'); const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(events.length, 1); assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail'); assert.equal(events[0].event, "new_mail");
}); });
test('多行 data 用换行拼接', () => { test("多行 data 用换行拼接", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('event: x\ndata: line1\ndata: line2\n\n'); const events = p.push("event: x\ndata: line1\ndata: line2\n\n");
assert.equal(events[0].data, 'line1\nline2'); assert.equal(events[0].data, "line1\nline2");
}); });
test('CRLF 不被当成事件名或 JSON 的一部分', () => { test("CRLF 不被当成事件名或 JSON 的一部分", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n'); const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
assert.equal(events.length, 1); assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail'); assert.equal(events[0].event, "new_mail");
assert.equal(events[0].id, '3'); assert.equal(events[0].id, "3");
assert.deepEqual(parse(events[0].data), { n: 1 }); assert.deepEqual(parse(events[0].data), { n: 1 });
}); });
test('事件 id 只向前推进:重放旧 id 不回退断点', () => { test("事件 id 只向前推进:重放旧 id 不回退断点", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n'); p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '10'); assert.equal(p.lastEventId(), "10");
// 服务端重放一条更早的事件:断点不该退回 5否则下次重连会重复回放 6..10 // 服务端重放一条更早的事件:断点不该退回 5否则下次重连会重复回放 6..10
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n'); p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id是否回退由使用方决定'); assert.equal(
p.lastEventId(),
"5",
"解析器如实记录当前 id是否回退由使用方决定",
);
}); });
test('id 在派发前记录:回调抛异常也不丢断点', () => { test("id 在派发前记录:回调抛异常也不丢断点", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n'); p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '42'); assert.equal(p.lastEventId(), "42");
}); });
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => { test("只有 data 没有 event 不派发(避免把心跳数据当事件)", () => {
const p = createFrameParser(); const p = createFrameParser();
const events = p.push('data: {"orphan":true}\n\n'); const events = p.push('data: {"orphan":true}\n\n');
assert.deepEqual(events, []); assert.deepEqual(events, []);
}); });
test('reset 清缓冲但保留断点(重连后仍能续传)', () => { test("reset 清缓冲但保留断点(重连后仍能续传)", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n'); p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
p.push('event: partial'); // 半帧 p.push("event: partial"); // 半帧
p.reset(); p.reset();
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放'); assert.equal(p.lastEventId(), "99", "断点必须保留,否则重连从头回放");
// reset 后半帧不该复活 // reset 后半帧不该复活
const after = p.push('data: {"n":2}\n\n'); const after = p.push('data: {"n":2}\n\n');
assert.deepEqual(after, []); assert.deepEqual(after, []);
}); });
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => { test("setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端", () => {
const p = createFrameParser(); const p = createFrameParser();
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n'); p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '123'); assert.equal(p.lastEventId(), "123");
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉 // connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
p.setLastEventId(''); p.setLastEventId("");
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID'); assert.equal(p.lastEventId(), "", "首次连接不得携带 Last-Event-ID");
}); });

View File

@ -404,4 +404,3 @@ func contains(list []string, v string) bool {
} }
return false return false
} }