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_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) } } }