主人 2026-10-03 过目 design/22 §8.1 草案并点头后实现。
- 新包 internal/runtime/sessionread:工具壳 + 只读取数 + 归属排版(按职责拆 8 文件)
- 只读由类型保证:注入的 HistoryReader 只声明 List/ListSessions —— 本包结构上无法写入
任何会话、也推进不了目标会话的 read_at
- fail-closed 授权,顺序 = 环境 → 白名单 → 存在性:
非 owner 环境拒(ErrNotOwner)→ 白名单只许 web / web:<uuid>(ErrSessionNotAllowed)
→ 会话不存在是硬错误(ErrSessionNotFound,绝不返回空材料,防"读不到"被当成"对方没说")
- 排版:头部三行恒定(外部会话材料 · 只读 · 不属于你的记忆 / 来源角色 / 来源会话)
+ 时间跨度与翻页游标 + 逐条「[时间] 说话人:」归属
- 说话人取消息自身 persona_id;'' → 全局默认角色;未知 id 原样显示;user → SenderName(空=主人)
- Execute 返回 string:loop 的 formatToolResult 对 string 原样透传,换行不被 json 转义
- 接线 cmd/cyrene/wire.go 一行 + wireSessionReadTool():能力默认标记(双 false)= owner-only,
不新增任何 bypass 路径;审计仍走 loop 的 gate
- 日志 source=runtime/sessionread,只记 actor/target/count/limit/before/has_older,不含正文
验证:go build/vet ./... 绿;internal/runtime/sessionread(25 测试)+ cmd/cyrene 绿;
linux/amd64 + darwin/arm64 交叉编译绿;
TestWireSessionReadToolRegistersOwnerOnly 断言 owner 放行 / group_member / stranger 拒绝
遗留(登记,未解决):§8.1 约束 4 要求"每次调用落审计",但 loop 的 gate 只在拒绝时发
AuditEvent ⇒ 成功读取目前只有日志可追溯,未进审计库;补 loop 侧成功路径钩子需先定口径。
133 lines
4.8 KiB
Go
133 lines
4.8 KiB
Go
package sessionread
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
|
)
|
|
|
|
// mainFake is the fixture the dispatcher-level tests read through: an existing main
|
|
// session carrying two messages.
|
|
func mainFake() *fakeHistory {
|
|
return &fakeHistory{
|
|
sessions: []history.SessionMeta{{ID: history.MainSessionID, Title: "调试日志"}},
|
|
msgs: map[string][]history.Message{history.MainSessionID: {
|
|
{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "在的", PersonaID: "yuki", CreatedAt: at(21, 7)},
|
|
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "你好", CreatedAt: at(21, 4)},
|
|
}},
|
|
}
|
|
}
|
|
|
|
// TestNameIsSessionRead locks the registered tool name design/22 §8.1 fixes.
|
|
func TestNameIsSessionRead(t *testing.T) {
|
|
t.Parallel()
|
|
if got := New().Name(); got != "session_read" {
|
|
t.Fatalf("Name() = %q, want session_read", got)
|
|
}
|
|
}
|
|
|
|
// TestParametersSchema locks the argument contract: sessionId is required, limit and
|
|
// before are optional numbers, and limit deliberately declares no maximum — a value
|
|
// above the cap must be clamped by the tool rather than rejected by the validator as
|
|
// a malformed call.
|
|
func TestParametersSchema(t *testing.T) {
|
|
t.Parallel()
|
|
params, err := New().Parameters()
|
|
if err != nil {
|
|
t.Fatalf("Parameters: %v", err)
|
|
}
|
|
if params["type"] != "object" {
|
|
t.Fatalf("schema type = %v, want object", params["type"])
|
|
}
|
|
required, ok := params["required"].([]any)
|
|
if !ok || len(required) != 1 || required[0] != "sessionId" {
|
|
t.Fatalf("required = %#v, want [sessionId]", params["required"])
|
|
}
|
|
props, ok := params["properties"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("properties = %#v, want an object", params["properties"])
|
|
}
|
|
for _, name := range []string{"sessionId", "limit", "before"} {
|
|
prop, ok := props[name].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("property %q missing from the schema", name)
|
|
}
|
|
if desc, _ := prop["description"].(string); strings.TrimSpace(desc) == "" {
|
|
t.Fatalf("property %q has no description; the model cannot use it", name)
|
|
}
|
|
}
|
|
if limit := props["limit"].(map[string]any); limit["type"] != "integer" {
|
|
t.Fatalf("limit type = %v, want integer", limit["type"])
|
|
} else if _, bounded := limit["maximum"]; bounded {
|
|
t.Fatal("limit declares a maximum; an over-cap value must be clamped, not rejected")
|
|
}
|
|
if before := props["before"].(map[string]any); before["type"] != "integer" {
|
|
t.Fatalf("before type = %v, want integer", before["type"])
|
|
}
|
|
}
|
|
|
|
// TestDescriptionMarksTheMaterialAsExternal locks the wording the model sees: it has
|
|
// to say the result is a read-only transcript of SOMEBODY ELSE's session, because
|
|
// that label is what keeps her from quoting it as her own memory (design/22 §8-3).
|
|
func TestDescriptionMarksTheMaterialAsExternal(t *testing.T) {
|
|
t.Parallel()
|
|
desc := New().Description()
|
|
for _, want := range []string{"read-only", "your own memory", "web:<uuid>"} {
|
|
if !strings.Contains(desc, want) {
|
|
t.Fatalf("Description() = %q, want it to mention %q", desc, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDispatcherInvokesTheTool drives the tool through the real dispatcher, which
|
|
// validates the arguments against Parameters() and coerces a decoded-JSON number
|
|
// (float64) to the schema's integer. It is the closest thing to the model's own
|
|
// call path that a unit test can exercise.
|
|
func TestDispatcherInvokesTheTool(t *testing.T) {
|
|
t.Parallel()
|
|
reg := registry.New()
|
|
d, err := tool.New(reg)
|
|
if err != nil {
|
|
t.Fatalf("tool.New: %v", err)
|
|
}
|
|
if err := d.Register(newReader(mainFake(), testPersonas())); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
out, err := d.Invoke(ownerCtx(), ToolName, map[string]any{"sessionId": "web", "limit": float64(1)})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
text, ok := out.(string)
|
|
if !ok {
|
|
t.Fatalf("Invoke returned %T, want a string material", out)
|
|
}
|
|
if !strings.Contains(text, "在的") || strings.Contains(text, "你好") {
|
|
t.Fatalf("material = %q, want only the newest message (limit 1 via float64)", text)
|
|
}
|
|
}
|
|
|
|
// TestDispatcherRejectsNoSessionID asserts the schema's required field is enforced
|
|
// before Execute, so a target-less call never reaches the reader.
|
|
func TestDispatcherRejectsNoSessionID(t *testing.T) {
|
|
t.Parallel()
|
|
reg := registry.New()
|
|
d, err := tool.New(reg)
|
|
if err != nil {
|
|
t.Fatalf("tool.New: %v", err)
|
|
}
|
|
h := mainFake()
|
|
if err := d.Register(newReader(h, testPersonas())); err != nil {
|
|
t.Fatalf("register: %v", err)
|
|
}
|
|
if _, err := d.Invoke(ownerCtx(), ToolName, map[string]any{}); !errors.Is(err, tool.ErrInvalidArgs) {
|
|
t.Fatalf("err = %v, want ErrInvalidArgs", err)
|
|
}
|
|
if h.listCalls != 0 {
|
|
t.Fatalf("history was read %d times for a target-less call, want 0", h.listCalls)
|
|
}
|
|
}
|