- 只读 S3-A 的 sidecar 正文(首行元数据行不计入 text),按字符分页,带 truncated/nextOffset 续读语义与 extractTruncated(区分「正文到此为止」与「原文件到此为止」)。 - 越界三态不静默:offset<0 / offset>totalChars → ErrDocumentOffset(碰盘前拦下,文案带总字符数);offset==totalChars 合法(读到末尾)。名非法/无 sidecar/unreadable → 哨兵错误 + 中文文案,绝不返回空正文假装成功。 - 读侧出口收敛到 documents 包:新增 ReadSidecar(含 Sidecar/Readable/ErrSidecarUnavailable),sidecar.go 的 load 变薄壳且对外行为逐字不变 ⇒ sidecar 形状与名字形态校验全仓只有一份实现。 - 注册与既有 6 个 io 工具同一路径(tools.go),策略默认 owner-only(群聊/陌生人默认调不到)。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 6 个路径)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、io 包 60 PASS;变异独立复现(工具名常量改成 document_read ⇒ 注册名单两条用例红)。 - 报告里发现的真实断裂(清单游标恒等于 sidecar 正文长度 ⇒ 照它续读必空)已登记,并入 T1 以「sidecar 存全文 + 注入预算与抽取预算解耦」根治。
448 lines
19 KiB
Go
448 lines
19 KiB
Go
package io
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf8"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||
)
|
||
|
||
// docName 造一个形态合法的服务端存储名:32 位小写十六进制主名 + 扩展名。
|
||
func docName(ext string) string { return strings.Repeat("ab12", 8) + ext }
|
||
|
||
// docReadFixture 是一份隔离的数据目录 + **真实上游解析器**。夹具里的 sidecar 一律由
|
||
// documents.Resolver(S3-A 的生产者)写出,测试因此不复制「首行元数据 + 正文自第二行
|
||
// 起」的第二份实现:生产者与消费者在同一份夹具里对接,sidecar 形状一漂移测试就红。
|
||
type docReadFixture struct {
|
||
dataDir string
|
||
uploads string
|
||
resolver *documents.Resolver
|
||
}
|
||
|
||
func newDocReadFixture(t *testing.T) *docReadFixture {
|
||
t.Helper()
|
||
dataDir := t.TempDir()
|
||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
||
t.Fatalf("建上传目录: %v", err)
|
||
}
|
||
return &docReadFixture{
|
||
dataDir: dataDir,
|
||
uploads: uploads,
|
||
resolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
|
||
}
|
||
}
|
||
|
||
// extract 落一份上传件并让装配期解析器跑一次(写出 sidecar),返回结论与**注入正文**:
|
||
// 后者就是模型在清单行下面看到的正文,也是工具 offset/limit 的坐标系。
|
||
func (f *docReadFixture) extract(t *testing.T, name, content string) (history.DocumentRef, string) {
|
||
t.Helper()
|
||
if err := os.WriteFile(filepath.Join(f.uploads, name), []byte(content), 0o600); err != nil {
|
||
t.Fatalf("写上传件: %v", err)
|
||
}
|
||
msg := model.DocumentMessage(model.RoleUser, "", model.Document{Name: name, MIME: "text/plain", Size: int64(len(content))})
|
||
refs, bodies, err := f.resolver.Resolve(context.Background(), msg)
|
||
if err != nil {
|
||
t.Fatalf("解析 %q: %v", name, err)
|
||
}
|
||
return refs[name], bodies[name]
|
||
}
|
||
|
||
// sidecarPath 是某份文档的 sidecar 路径(与上游同一命名规则,供断言用)。
|
||
func (f *docReadFixture) sidecarPath(name string) string {
|
||
return filepath.Join(f.dataDir, "extracted", name+".txt")
|
||
}
|
||
|
||
// tool 返回指向同一份数据目录的 document-read 工具。
|
||
func (f *docReadFixture) tool(t *testing.T) *documentReadTool {
|
||
t.Helper()
|
||
got, ok := findTool(t, buildTools(t, &config.Config{DataDir: f.dataDir}), documentReadToolName).(*documentReadTool)
|
||
if !ok {
|
||
t.Fatalf("%q 工具类型不符", documentReadToolName)
|
||
}
|
||
return got
|
||
}
|
||
|
||
// callDocRead 执行一次 document-read,并把返回的 JSON 文本解回结果结构。工具返回的是
|
||
// **字符串**(循环的 formatToolResult 对字符串直通,模型看到裸 JSON),这里因此必须反
|
||
// 序列化才能断言字段 —— 它同时钉住「返回值是 JSON 文本」这条约定。
|
||
func callDocRead(t *testing.T, tl *documentReadTool, args map[string]any) (documentReadResult, error) {
|
||
t.Helper()
|
||
raw, err := tl.Execute(context.Background(), args)
|
||
if err != nil {
|
||
return documentReadResult{}, err
|
||
}
|
||
text, ok := raw.(string)
|
||
if !ok {
|
||
t.Fatalf("返回值类型 = %T, want string(JSON 文本)", raw)
|
||
}
|
||
var out documentReadResult
|
||
if err := json.Unmarshal([]byte(text), &out); err != nil {
|
||
t.Fatalf("返回值不是合法 JSON: %v(%q)", err, text)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// TestDocumentReadTool_PaginatesWithNextOffset 钉住①:首读 + 用 nextOffset 续读,两段
|
||
// 拼接逐字等于装配期注入的正文(两边同一坐标系),游标单位为字符而非字节。
|
||
func TestDocumentReadTool_PaginatesWithNextOffset(t *testing.T) {
|
||
t.Parallel()
|
||
fx := newDocReadFixture(t)
|
||
name := docName(".txt")
|
||
body := "季度报告:营收增长 12%。\n" + strings.Repeat("第二段:成本下降 3%。\n", 12)
|
||
_, injected := fx.extract(t, name, body)
|
||
tool := fx.tool(t)
|
||
total := utf8.RuneCountInString(injected)
|
||
half := total / 2
|
||
|
||
first, err := callDocRead(t, tool, map[string]any{"name": name, "limit": half})
|
||
if err != nil {
|
||
t.Fatalf("首读: %v", err)
|
||
}
|
||
if first.Status != "extracted" || first.Offset != 0 || first.TotalChars != total {
|
||
t.Fatalf("首读 = %+v, want extracted / offset 0 / totalChars %d", first, total)
|
||
}
|
||
if !first.Truncated || first.NextOffset != half || utf8.RuneCountInString(first.Text) != half {
|
||
t.Fatalf("首读截断信息 = %+v, want truncated + nextOffset %d + 正文 %d 字符", first, half, half)
|
||
}
|
||
second, err := callDocRead(t, tool, map[string]any{"name": name, "offset": first.NextOffset})
|
||
if err != nil {
|
||
t.Fatalf("续读: %v", err)
|
||
}
|
||
if second.Offset != half || second.Truncated || second.NextOffset != 0 {
|
||
t.Fatalf("续读 = %+v, want 读到末尾(offset %d、无续读游标)", second, half)
|
||
}
|
||
if first.Text+second.Text != injected {
|
||
t.Fatalf("两段拼接 %q + %q != 注入正文 %q", first.Text, second.Text, injected)
|
||
}
|
||
if second.Text != string([]rune(injected)[half:]) {
|
||
t.Fatalf("续读正文 = %q, want 注入正文的后半段", second.Text)
|
||
}
|
||
}
|
||
|
||
// TestDocumentReadTool_ContinuesAcrossPages 钉住「续读之后**仍然**被 limit 截断」这条
|
||
// 路径:游标必须一页页往前走(既不原地打转,也不吞掉中间的内容),多页拼接逐字等于
|
||
// 注入正文。
|
||
func TestDocumentReadTool_ContinuesAcrossPages(t *testing.T) {
|
||
t.Parallel()
|
||
fx := newDocReadFixture(t)
|
||
name := docName(".txt")
|
||
_, injected := fx.extract(t, name, strings.Repeat("一行正文,用于多页续读。\n", 20))
|
||
tool := fx.tool(t)
|
||
total := utf8.RuneCountInString(injected)
|
||
step := total/4 + 1 // 至少 3 页,因此中间那些页后面还有内容
|
||
|
||
var joined string
|
||
offset, pages := 0, 0
|
||
for {
|
||
if pages++; pages > 8 {
|
||
t.Fatalf("分页不收敛:offset 停在 %d", offset)
|
||
}
|
||
page, err := callDocRead(t, tool, map[string]any{"name": name, "offset": offset, "limit": step})
|
||
if err != nil {
|
||
t.Fatalf("第 %d 页: %v", pages, err)
|
||
}
|
||
if page.Offset != offset {
|
||
t.Fatalf("第 %d 页 offset = %d, want %d", pages, page.Offset, offset)
|
||
}
|
||
// limit 是**上限**:任何一页都不得超出(多给会让模型的上下文预算失控)。
|
||
if n := utf8.RuneCountInString(page.Text); n > step {
|
||
t.Fatalf("第 %d 页返回 %d 字符, 超过 limit %d", pages, n, step)
|
||
}
|
||
joined += page.Text
|
||
if page.Truncated && utf8.RuneCountInString(page.Text) != step {
|
||
t.Fatalf("第 %d 页被截断却只给了 %d 字符, want %d", pages, utf8.RuneCountInString(page.Text), step)
|
||
}
|
||
if !page.Truncated {
|
||
if page.NextOffset != 0 {
|
||
t.Fatalf("末页仍带续读游标 %d", page.NextOffset)
|
||
}
|
||
break
|
||
}
|
||
if want := offset + utf8.RuneCountInString(page.Text); page.NextOffset != want {
|
||
t.Fatalf("第 %d 页续读游标 = %d, want %d", pages, page.NextOffset, want)
|
||
}
|
||
offset = page.NextOffset
|
||
}
|
||
if pages < 3 {
|
||
t.Fatalf("只读到 %d 页,夹具没有覆盖「续读后仍被截断」", pages)
|
||
}
|
||
if joined != injected {
|
||
t.Fatalf("多页拼接 = %d 字符, want 注入正文 %d 字符", utf8.RuneCountInString(joined), total)
|
||
}
|
||
}
|
||
|
||
// TestDocumentReadTool_LimitDefaultAndCap 钉住 limit 的缺省与上限:缺省取
|
||
// defaultDocumentReadChars,超过上限的请求夹到 maxDocumentReadChars —— 正文本身就在同一
|
||
// 上限内(抽取期预算),因此夹取只会削掉「多要了」的部分,不会少给已存在的正文。
|
||
func TestDocumentReadTool_LimitDefaultAndCap(t *testing.T) {
|
||
t.Parallel()
|
||
fx := newDocReadFixture(t)
|
||
name := docName(".txt")
|
||
_, injected := fx.extract(t, name, "短正文:只有这一行。\n")
|
||
tool := fx.tool(t)
|
||
|
||
got, err := callDocRead(t, tool, map[string]any{"name": name})
|
||
if err != nil {
|
||
t.Fatalf("缺省读取: %v", err)
|
||
}
|
||
if got.Limit != defaultDocumentReadChars || got.Truncated || got.NextOffset != 0 || got.Text != injected {
|
||
t.Fatalf("缺省读取 = %+v, want limit %d、无截断、正文 %q", got, defaultDocumentReadChars, injected)
|
||
}
|
||
capped, err := callDocRead(t, tool, map[string]any{"name": name, "limit": maxDocumentReadChars * 2})
|
||
if err != nil {
|
||
t.Fatalf("超大 limit 读取: %v", err)
|
||
}
|
||
if capped.Limit != maxDocumentReadChars {
|
||
t.Fatalf("limit 夹取 = %d, want %d", capped.Limit, maxDocumentReadChars)
|
||
}
|
||
}
|
||
|
||
// TestDocumentReadTool_OffsetOutOfRange 钉住②:负 offset 在碰盘之前就被拒,超过正文长度
|
||
// 的 offset 也不静默给空,而「恰好等于正文长度」是合法的「读到末尾」。
|
||
func TestDocumentReadTool_OffsetOutOfRange(t *testing.T) {
|
||
t.Parallel()
|
||
fx := newDocReadFixture(t)
|
||
name := docName(".txt")
|
||
_, injected := fx.extract(t, name, "abcdefghij\n")
|
||
tool := fx.tool(t)
|
||
total := utf8.RuneCountInString(injected)
|
||
|
||
// 负数:用一份**从未抽取过**的名字证明它在读盘之前就被拒 —— 若实现先读盘,这里得到的
|
||
// 会是「没有可读的抽取结果」而不是 offset 错误。
|
||
if _, err := callDocRead(t, tool, map[string]any{"name": docName(".log"), "offset": -1}); !errors.Is(err, ErrDocumentOffset) {
|
||
t.Fatalf("负 offset → %v, want ErrDocumentOffset(应在碰盘前被拒)", err)
|
||
}
|
||
if _, err := callDocRead(t, tool, map[string]any{"name": name, "offset": total + 1}); !errors.Is(err, ErrDocumentOffset) {
|
||
t.Fatalf("越界 offset → %v, want ErrDocumentOffset", err)
|
||
}
|
||
last, err := callDocRead(t, tool, map[string]any{"name": name, "offset": total})
|
||
if err != nil {
|
||
t.Fatalf("末尾读取: %v", err)
|
||
}
|
||
if last.Text != "" || last.Truncated || last.NextOffset != 0 || last.TotalChars != total {
|
||
t.Fatalf("末尾读取 = %+v, want 空 text + 无续读 + totalChars %d", last, total)
|
||
}
|
||
}
|
||
|
||
// TestDocumentReadTool_RejectsIllegalName 钉住④(含「不碰盘」):形态非法的名字必须在触达
|
||
// 文件系统之前被拒。数据目录在这里被指向一条**不存在**的路径:若实现先读盘,得到的会是
|
||
// 「没有可读的抽取结果」,因此 errors.Is(ErrInvalidName) 本身就是「没碰盘」的证据。
|
||
func TestDocumentReadTool_RejectsIllegalName(t *testing.T) {
|
||
t.Parallel()
|
||
dataDir := filepath.Join(t.TempDir(), "missing")
|
||
tool, ok := findTool(t, buildTools(t, &config.Config{DataDir: dataDir}), documentReadToolName).(*documentReadTool)
|
||
if !ok {
|
||
t.Fatalf("%q 工具类型不符", documentReadToolName)
|
||
}
|
||
illegal := []string{
|
||
"", // 空名字
|
||
"../secret.txt", // 路径穿越
|
||
"..", // 父目录
|
||
"a.txt", // 主名不足 32 位
|
||
"ab12ab12ab12ab12ab12ab12ab12ab1.txt", // 31 位主名
|
||
docName(".TXT"), // 大写扩展名
|
||
strings.Repeat("g", 32) + ".txt", // 主名不是十六进制
|
||
docName("." + strings.Repeat("x", 13)), // 扩展名超长(>12)
|
||
"extracted/" + docName(".txt"), // 含目录分隔符
|
||
`C:\Windows\x.txt`, // 绝对路径
|
||
"ab12ab12ab12ab12ab12ab12ab12ab12..txt", // 扩展名里带点
|
||
}
|
||
for _, name := range illegal {
|
||
if _, err := callDocRead(t, tool, map[string]any{"name": name}); !errors.Is(err, documents.ErrInvalidName) {
|
||
t.Fatalf("name %q → %v, want documents.ErrInvalidName", name, err)
|
||
}
|
||
}
|
||
if _, err := tool.Execute(context.Background(), "not-an-object"); err == nil {
|
||
t.Fatal("非对象参数应当报错")
|
||
}
|
||
entries, err := os.ReadDir(filepath.Join(dataDir, "extracted"))
|
||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
t.Fatalf("读 extracted 目录: %v", err)
|
||
}
|
||
if len(entries) != 0 {
|
||
t.Fatalf("非法名字竟然产生了 %d 个文件", len(entries))
|
||
}
|
||
// 扩展名白名单是 S1 存储侧的裁决(design/24 §4.2 末:不重复存储侧校验、不扩大访问面):
|
||
// 形态合法但 S1 永不会签发的扩展名因此走到「没有可读的抽取结果」这条诚实错误。
|
||
if _, err := callDocRead(t, tool, map[string]any{"name": docName(".exe")}); !errors.Is(err, documents.ErrSidecarUnavailable) {
|
||
t.Fatalf("形态合法但无对应 sidecar → %v, want documents.ErrSidecarUnavailable", err)
|
||
}
|
||
}
|
||
|
||
// TestDocumentReadTool_MissingSidecarIsHonest 钉住③(缺失)与「只读 sidecar,绝不重抽」:
|
||
// 没有 sidecar 时给的是诚实错误,而不是空正文、也不是回头重抽原文件。
|
||
func TestDocumentReadTool_MissingSidecarIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
fx := newDocReadFixture(t)
|
||
name := docName(".txt")
|
||
tool := fx.tool(t)
|
||
|
||
if _, err := callDocRead(t, tool, map[string]any{"name": name}); !errors.Is(err, documents.ErrSidecarUnavailable) {
|
||
t.Fatalf("无 sidecar → %v, want documents.ErrSidecarUnavailable", err)
|
||
}
|
||
// 抽取过、随后 sidecar 被清掉:原文件仍在,但工具**绝不**因此回头重抽。
|
||
_, injected := fx.extract(t, name, "正文在 sidecar 里。\n")
|
||
if err := os.Remove(fx.sidecarPath(name)); err != nil {
|
||
t.Fatalf("删除 sidecar: %v", err)
|
||
}
|
||
if _, err := callDocRead(t, tool, map[string]any{"name": name}); !errors.Is(err, documents.ErrSidecarUnavailable) {
|
||
t.Fatalf("sidecar 被清掉后 → %v, want documents.ErrSidecarUnavailable(不得回退重抽)", err)
|
||
}
|
||
if injected == "" {
|
||
t.Fatal("夹具前提:抽取应当产出正文")
|
||
}
|
||
}
|
||
|
||
// TestDocumentReadTool_UnreadableIsHonest 钉住③(unreadable):sidecar 的结论是读不到正文
|
||
// 时,工具给的是**带原因的**诚实错误,而不是空正文假装成功。
|
||
func TestDocumentReadTool_UnreadableIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
fx := newDocReadFixture(t)
|
||
name := docName(".pdf")
|
||
ref, _ := fx.extract(t, name, "%PDF-1.7\n")
|
||
if ref.Status != history.DocumentStatusUnreadable {
|
||
t.Fatalf("夹具前提不成立:status = %q, want %q", ref.Status, history.DocumentStatusUnreadable)
|
||
}
|
||
_, err := callDocRead(t, fx.tool(t), map[string]any{"name": name})
|
||
if !errors.Is(err, ErrDocumentUnreadable) {
|
||
t.Fatalf("unreadable → %v, want ErrDocumentUnreadable", err)
|
||
}
|
||
// 抽取侧给的原因短句必须抵达错误文案(那是模型/用户唯一能看到的解释);这里断言的
|
||
// 是全部原因文案共有的前缀,不是某一句具体措辞。
|
||
if !strings.Contains(err.Error(), "无法抽取正文") {
|
||
t.Fatalf("错误文案没带上抽取侧的原因:%v", err)
|
||
}
|
||
}
|
||
|
||
// TestDocumentReadTool_TextExcludesMetadataLine 钉住⑥:sidecar 第一行的元数据绝不混进
|
||
// text(正文自第二行起),返回的正文与装配期注入的正文逐字相同。
|
||
func TestDocumentReadTool_TextExcludesMetadataLine(t *testing.T) {
|
||
t.Parallel()
|
||
fx := newDocReadFixture(t)
|
||
name := docName(".txt")
|
||
body := "正文第一行\n正文第二行\n"
|
||
_, injected := fx.extract(t, name, body)
|
||
if injected != body {
|
||
t.Fatalf("夹具前提:注入正文 = %q, want %q", injected, body)
|
||
}
|
||
got, err := callDocRead(t, fx.tool(t), map[string]any{"name": name})
|
||
if err != nil {
|
||
t.Fatalf("读取: %v", err)
|
||
}
|
||
if got.Text != injected {
|
||
t.Fatalf("text = %q, want 注入正文 %q", got.Text, injected)
|
||
}
|
||
// 磁盘上的 sidecar 确实带一行元数据(否则上面的相等只是「本来就没有元数据」的假绿)。
|
||
raw, err := os.ReadFile(fx.sidecarPath(name))
|
||
if err != nil {
|
||
t.Fatalf("读 sidecar: %v", err)
|
||
}
|
||
if !strings.HasPrefix(string(raw), "{") || !strings.Contains(string(raw), "\n") {
|
||
t.Fatalf("sidecar 首行不是元数据:%q", string(raw))
|
||
}
|
||
for _, leak := range []string{`"status"`, `"v":`, `"chars"`} {
|
||
if strings.Contains(got.Text, leak) {
|
||
t.Fatalf("text 混进了 sidecar 元数据(含 %s):%q", leak, got.Text)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDocumentReadTool_ManifestCursorAndCSVSummary 钉住与上游的两处成对契约:
|
||
// - CSV 的摘要行属于正文坐标系(offset 0 = 摘要行开头),与装配期注入的正文一致;
|
||
// - 清单行给的续读游标(= 已注入字符数)可以原样传进来:读到末尾不报错,并如实标注
|
||
// 抽取期的截断(extractTruncated)—— 抽取期预算之外的内容**不在 sidecar 里**
|
||
// (design/24 §9.6 第 3 条),这条事实必须让模型看得见。
|
||
func TestDocumentReadTool_ManifestCursorAndCSVSummary(t *testing.T) {
|
||
t.Parallel()
|
||
fx := newDocReadFixture(t)
|
||
tool := fx.tool(t)
|
||
|
||
csv := docName(".csv")
|
||
_, injectedCSV := fx.extract(t, csv, "a,b\n1,2\n3,4\n")
|
||
if !strings.HasPrefix(injectedCSV, "[CSV") {
|
||
t.Fatalf("CSV 夹具应当带摘要行:%q", injectedCSV)
|
||
}
|
||
gotCSV, err := callDocRead(t, tool, map[string]any{"name": csv})
|
||
if err != nil {
|
||
t.Fatalf("读 CSV: %v", err)
|
||
}
|
||
if gotCSV.Text != injectedCSV {
|
||
t.Fatalf("CSV text = %q, want 含摘要行的注入正文 %q", gotCSV.Text, injectedCSV)
|
||
}
|
||
|
||
big := docName(".txt")
|
||
ref, _ := fx.extract(t, big, strings.Repeat("宽", documents.MaxExtractChars+10))
|
||
if !ref.Truncated || ref.Chars != documents.MaxExtractChars {
|
||
t.Fatalf("夹具前提:ref = %+v, want 抽取期已截断到 %d 字符", ref, documents.MaxExtractChars)
|
||
}
|
||
atCursor, err := callDocRead(t, tool, map[string]any{"name": big, "offset": ref.Chars})
|
||
if err != nil {
|
||
t.Fatalf("按清单游标读取: %v", err)
|
||
}
|
||
if atCursor.TotalChars != ref.Chars || atCursor.Text != "" || atCursor.Truncated || atCursor.NextOffset != 0 {
|
||
t.Fatalf("按清单游标读取 = %+v, want 读到抽取正文末尾(totalChars %d)", atCursor, ref.Chars)
|
||
}
|
||
if !atCursor.ExtractTruncated {
|
||
t.Fatal("抽取期的截断事实没有如实回给模型(extractTruncated=false)")
|
||
}
|
||
}
|
||
|
||
// TestDocumentReadTool_OwnerOnlyByDefault 钉住⑤:工具以**默认最严**的可达性标记注册
|
||
// (tool_policies 无行 = 两者皆 false = owner-only),因此群聊/陌生人会话在默认策略下既
|
||
// 看不到也调不到它(design/24 §5 红线)。注册走的是既有工具同一条路径
|
||
// (dispatcher.Register → registry.RegisterTool),因此它同样受 CAPACITY_TOOL_SETS 裁剪。
|
||
func TestDocumentReadTool_OwnerOnlyByDefault(t *testing.T) {
|
||
t.Parallel()
|
||
fx := newDocReadFixture(t)
|
||
reg := registry.New()
|
||
dispatcher, err := tool.New(reg)
|
||
if err != nil {
|
||
t.Fatalf("tool.New: %v", err)
|
||
}
|
||
for _, tl := range buildTools(t, &config.Config{DataDir: fx.dataDir}) {
|
||
if rerr := dispatcher.Register(tl); rerr != nil {
|
||
t.Fatalf("注册 %q: %v", tl.Name(), rerr)
|
||
}
|
||
}
|
||
cap, err := reg.Get(documentReadToolName)
|
||
if err != nil {
|
||
t.Fatalf("%q 未注册: %v", documentReadToolName, err)
|
||
}
|
||
if cap.Kind != registry.KindTool {
|
||
t.Fatalf("kind = %q, want %q", cap.Kind, registry.KindTool)
|
||
}
|
||
if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger {
|
||
t.Fatalf("默认可达性标记 = %+v, want 两者皆 false(最严)", cap.Policy)
|
||
}
|
||
for _, tc := range []struct {
|
||
name string
|
||
env restrict.ActorEnv
|
||
want bool
|
||
}{
|
||
{"owner", restrict.ActorOwner, true},
|
||
{"group", restrict.ActorGroupMember, false},
|
||
{"stranger", restrict.ActorStranger, false},
|
||
} {
|
||
if got := restrict.SetContext(reg, tc.env).Allow(documentReadToolName); got != tc.want {
|
||
t.Fatalf("%s 环境 Allow = %v, want %v", tc.name, got, tc.want)
|
||
}
|
||
// 循环真正用的那道门(dispatcher 的策略引擎)必须与策略引擎同一结论。
|
||
if got := dispatcher.PolicyEngine(tc.env).Allow(documentReadToolName); got != tc.want {
|
||
t.Fatalf("%s 环境 PolicyEngine().Allow = %v, want %v", tc.name, got, tc.want)
|
||
}
|
||
}
|
||
}
|