Files
MailUI4Agents/plugins/homeagent-mail-bridge/default_session_test.go
JianFeeeee de6b91516a feat(默认会话): 非邮件轮次用 /tmp 默认会话作合法 session_id —— 配套 15e4fe9 的收严
`15e4fe9` 让未声明 session_id 的读信一律 403,而 homeagent 的工具**全局可调** ⇒
对话里自主调 read_mail/read_thread 时 `currentSessionID` 为空 ⇒ 403。
不能因此让「非邮件轮次读信」这个能力消失(它是 10-01 那个 read_inbox 修复的
用户可见部分),所以给它一个合法声明。

## 关键约束:workspace 能回落 cwd,session_id 不能

`session_id` 是 AgentMail 会话的 UUID,进程 cwd 给不出它 ⇒ 只能问服务端。
落点选 `/tmp`:非邮件轮次没有真实工作目录,而 /tmp 是中性落点(不属于任何真实
项目,不会把项目邮件混进来),且满足 `UnreadWorkspaces` 的 `workspace LIKE '/%'`
(能被寻址补投)。

## 服务端:`GET /api/v1/agent/session/default`

**复用**已有的默认会话语义(`FindOrCreateDefaultSession`,8 个测试覆盖),
只把它开放成可查询形状 —— 不新造概念。

★ 第一版调 `FindOrCreateDefaultSessionCreated`,判据当场报**每次都新建**
(连问两次得到两个不同 UUID)。根因:那个函数的复用条件含
`EXISTS (SELECT 1 FROM mails …)`,空会话不满足 ⇒ 永远「没找到可复用」。
改「先查后建」仍不够。想深一层:**根本不该建** —— 非邮件轮次若 `name@/tmp`
一封都没通过,收件箱本来就该是空的,不需要一条 id 才能表达「空」。
⇒ 改成**纯只读**:没通信过就返回 `session_id: null`。
GET 有副作用是坏味道,它会被桥每轮调一次。

同时把匹配 SQL 抽成 `defaultSessionMatchSQL` 共享常量:`FindExisting` 与
`FindOrCreate` 必须给出**同一个**答案,否则「查到的默认会话」与「发信落进去的
会话」会静默分叉(各写一份 SQL 的话,改一边不会红)。

## 桥(homeagent):effectiveSessionID = 信封 → 默认会话

⚠ 取值函数**不发请求**。我第一版把 HTTP 塞进 `effectiveSessionID`,
`&Plugin{}` 构造的测试当场 nil panic,且 scopeQuery 变成「拼 URL 时顺带发请求」。
IO 移到装配期 `register()` 里的 `ensureDefaultSession()`。

⚠ `client == nil` 时**不标记已问** —— 那不是「答案是空」而是「还没资格问」,
标了会永久缓存空值。而 register() 里就会调它,真的会在插件加载阶段崩。

## 判据

服务端 6 格(含★「不是万能钥匙」:拿默认会话 id 去读别人的会话仍须 403 ——
少了这格,这个端点就是「声明一个合法会话然后读遍全场」的后门)。
homeagent 6 格。
三个变异各红 1 格:退回旧的整体放弃 / 未就绪也标记 / 默认落点与服务端不一致。

## 未改:pi / dsh / opencode

实测它们的裸奔已停止(pi 自 Sep 26、opencode 自 Sep 28,`[agent-scope]` 日志归零),
`getMailSessionId` 由 worker 闭包注入且只有一处装配点。dsh 待单独核。
2026-10-02 01:09:19 +08:00

117 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"os"
"strings"
"testing"
)
/*
非邮件轮次必须能声明一个**合法**的 session_id(2026-10-02,B 方案配套)。
# 背景
服务端 `AgentMayReadSession` 收严后(`15e4fe9`),未声明 session_id 的读信
一律 403。而 homeagent 的工具**全局可调** ⇒ 对话里自主调 read_mail/read_thread 时
`currentSessionID` 为空 ⇒ 不带 session_id ⇒ 403。
「非邮件轮次读信」这个能力不能因此消失(它是 2026-10-01 那个
`read_inbox` 修复的用户可见部分),所以要给它一个合法声明。
# 为什么回落答案必须向服务端问
`workspace` 能回落到进程 cwd,而 **session_id 是 AgentMail 会话的 UUID,
cwd 给不出它**。所以只有一条路:`GET /agent/session/default?workspace=/tmp`
(服务端纯只读;没通信过就返回 session_id=null,不建会话)。
*/
// ① 取值函数 effectiveSessionID **不发请求**(纯取值)。
//
// 我第一版把 HTTP 塞进这里,于是 `&Plugin{}` 构造的测试直接 nil panic
// (p.client 为 nil),而且 scopeQuery 变成「拼 URL 时顺带发一次请求」。
// 判据钉住这个形状:取值与 IO 混在一起,测试与真实调用都会变得难以推理。
func TestEffectiveSessionIDIsPureLookup(t *testing.T) {
p := &Plugin{}
// 不设置 client —— 若取值函数敢发请求,这里就 panic
if got := p.effectiveSessionID(); got != "" {
t.Fatalf("未探测且无当前会话时应为空,实际 %q", got)
}
}
// ② 邮件轮次内优先用信封上的 currentSessionID(不回落)。
func TestEffectiveSessionIDPrefersEnvelope(t *testing.T) {
p := &Plugin{currentSessionID: "env-session"}
if got := p.effectiveSessionID(); got != "env-session" {
t.Fatalf("轮次内必须用信封上的会话,实际 %q", got)
}
}
// ③ scopeQuery 在非邮件轮次、且已问出默认会话时,**仍要带 session_id**。
// 这是本次改动的核心形状:旧写法是 currentSessionID 为空就整体放弃。
func TestScopeQueryCarriesDefaultSessionOutsideTurn(t *testing.T) {
p := &Plugin{defaultSessionID: "sess-default-123"}
q := p.scopeQuery("&")
if !strings.Contains(q, "session_id=sess-default-123") {
t.Fatalf("★ 非邮件轮次也必须声明合法 session_id(否则服务端 403):%q", q)
}
}
// ④ 两样都拿不到时才放弃 —— 且是**明确放弃**(返回空串),
// 不是「退化成不带 session_id 的裸奔」。
func TestScopeQueryGivesUpOnlyWhenBothAbsent(t *testing.T) {
p := &Plugin{} // 既无 currentSessionID,也无 defaultSessionID
if q := p.scopeQuery("&"); q != "" {
t.Fatalf("两样都没有时整体放弃(服务端会 403,这是可见的错误而非静默放行):%q", q)
}
}
// ⑤ client 没就绪时**不该**标记「已问」——那不是「答案是空」,
// 而是「还没资格问」。标了就会把空值永久缓存,之后再也不会问。
//
// 这条是被判据打出来的:我第一版没这道判据,register() 里调它会在
// 插件加载阶段 nil panic;而加了判据后若还沿用旧断言,就会把
// 「正确地没标记」当成失败 —— 断言本身要跟着实现意图走,不能反过来。
func TestEnsureDefaultSessionDoesNotProbeBeforeClientReady(t *testing.T) {
p := &Plugin{} // client 未就绪
p.ensureDefaultSession()
if p.defaultSessionProbed {
t.Fatal("★ client 未就绪时不该标记已问(否则永久缓存空值,之后再也不问)")
}
if p.defaultSessionID != "" {
t.Fatalf("更不该有 id,实际 %q", p.defaultSessionID)
}
}
// ⑤b 一旦标记过就**不再重复**问(否则每个工具调用都给服务端添压力)。
// 这里直接检查缓存标记的粘性(真发 HTTP 的形状由 repo/handler 侧判据覆盖)。
func TestDefaultSessionProbeFlagIsSticky(t *testing.T) {
p := &Plugin{defaultSessionProbed: true}
p.ensureDefaultSession() // 不该再动任何状态
if !p.defaultSessionProbed {
t.Fatal("已问标记应是幂等的")
}
if p.defaultSessionID != "" {
t.Fatalf("标记已置时不该凭空产生 id,实际 %q", p.defaultSessionID)
}
}
// ⑥ 邮件轮次内**不**去问默认会话(那次问是纯浪费,且当前SessionID 已有值)。
func TestEnsureDefaultSessionSkipsInsideTurn(t *testing.T) {
p := &Plugin{currentSessionID: "env"}
p.ensureDefaultSession()
if p.defaultSessionProbed {
t.Fatal("★ 轮次内不该问默认会话(currentSessionID 已有值)")
}
}
// ⑦ 默认落点与服务端一致:/tmp。
// 两边不一致 = 桥问的是一个 key、服务端建的是另一个 key ⇒ 永远问不到。
func TestDefaultFallbackWorkspaceMatchesServer(t *testing.T) {
if defaultFallbackWorkspace != "/tmp" {
t.Fatalf("必须与 handler.DefaultFallbackWorkspace 一致,实际 %q", defaultFallbackWorkspace)
}
if !strings.HasPrefix(defaultFallbackWorkspace, os.Getenv("HOME")) &&
!strings.HasPrefix(defaultFallbackWorkspace, "/") {
t.Fatal("默认落点必须是绝对路径(UnreadWorkspaces 要求 workspace LIKE '/%')")
}
}