From 08b3a017524c8149105cadacf71a4227d079f024 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 3 Oct 2026 21:33:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(runtime,ai):=20session=5Fread=20=E2=80=94?= =?UTF-8?q?=20=E8=B7=A8=E4=BC=9A=E8=AF=9D=E5=8F=AA=E8=AF=BB=E3=80=8C?= =?UTF-8?q?=E4=B8=BB=E5=8A=A8=E8=AF=BB=E5=8F=96=E3=80=8D=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=EF=BC=88design/22=20=C2=A78-3/=C2=A78.1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 2026-10-03 过目 design/22 §8.1 草案并点头后实现。 - 新包 internal/runtime/sessionread:工具壳 + 只读取数 + 归属排版(按职责拆 8 文件) - 只读由类型保证:注入的 HistoryReader 只声明 List/ListSessions —— 本包结构上无法写入 任何会话、也推进不了目标会话的 read_at - fail-closed 授权,顺序 = 环境 → 白名单 → 存在性: 非 owner 环境拒(ErrNotOwner)→ 白名单只许 web / web:(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 侧成功路径钩子需先定口径。 --- cmd/cyrene/sessionread_tool.go | 97 ++++++ cmd/cyrene/sessionread_tool_test.go | 182 +++++++++++ cmd/cyrene/wire.go | 1 + internal/runtime/sessionread/doc.go | 21 ++ internal/runtime/sessionread/format.go | 218 +++++++++++++ internal/runtime/sessionread/format_test.go | 254 +++++++++++++++ internal/runtime/sessionread/helpers_test.go | 131 ++++++++ internal/runtime/sessionread/reader.go | 195 ++++++++++++ internal/runtime/sessionread/reader_test.go | 312 +++++++++++++++++++ internal/runtime/sessionread/tool.go | 180 +++++++++++ internal/runtime/sessionread/tool_test.go | 132 ++++++++ 11 files changed, 1723 insertions(+) create mode 100644 cmd/cyrene/sessionread_tool.go create mode 100644 cmd/cyrene/sessionread_tool_test.go create mode 100644 internal/runtime/sessionread/doc.go create mode 100644 internal/runtime/sessionread/format.go create mode 100644 internal/runtime/sessionread/format_test.go create mode 100644 internal/runtime/sessionread/helpers_test.go create mode 100644 internal/runtime/sessionread/reader.go create mode 100644 internal/runtime/sessionread/reader_test.go create mode 100644 internal/runtime/sessionread/tool.go create mode 100644 internal/runtime/sessionread/tool_test.go diff --git a/cmd/cyrene/sessionread_tool.go b/cmd/cyrene/sessionread_tool.go new file mode 100644 index 0000000..dbd1ef4 --- /dev/null +++ b/cmd/cyrene/sessionread_tool.go @@ -0,0 +1,97 @@ +package main + +import ( + "log/slog" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread" +) + +// wireSessionReadTool registers the design/22 §8-3 cross-session read tool, so the +// running role can pull another Web session's context as external, read-only +// material (design/22 §8.1). +// +// The capability is registered with its default reachability markers (both false), +// which keeps it owner-only: the design/11 §3 policy engine hides the schema from a +// group/stranger session and the loop's security gate rejects the call there. The +// tool's own fail-closed check (owner environment + the web/web: whitelist) +// runs behind that gate, so an unauthorized read is refused twice over. The +// audit trail is the loop's gate/OnAudit, exactly as for every other tool — no +// bypass path is added here. +func (a *app) wireSessionReadTool() { + log := logger.WithSource(a.log, "cmd/cyrene") + if a.history == nil { + log.Warn("session read tool skipped: no history store") + return + } + if a.dispatcher == nil { + log.Warn("session read tool skipped: no tool dispatcher") + return + } + reader := sessionread.New( + sessionread.WithHistory(a.history), + sessionread.WithPersonas(sessionReadPersonas{reg: a.personas}), + sessionread.WithLocation(a.sessionReadLocation(log)), + sessionread.WithLogger(a.log), + ) + if err := a.dispatcher.Register(reader); err != nil { + log.Warn("session read tool register skipped", "error", err) + return + } + log.Info("session read tool registered", "tool", sessionread.ToolName, "owner_only", true) +} + +// sessionReadLocation resolves the configured IANA zone for the material's +// timestamps, so the external transcript reads in the same clock as the loop's +// per-round "current time" injection (internal/runtime/loop/time.go). An unset zone +// or one that fails to load returns nil, which keeps the tool's process-local +// default — the read must not fail over a display setting. +func (a *app) sessionReadLocation(log *slog.Logger) *time.Location { + if a.cfg == nil || a.cfg.Timezone == "" { + return nil + } + loc, err := time.LoadLocation(a.cfg.Timezone) + if err != nil { + log.Warn("session read tool timezone load failed; using the process-local zone", + "tz", a.cfg.Timezone, "error", err) + return nil + } + return loc +} + +// sessionReadPersonas adapts the persona registry to sessionread.PersonaNamer. The +// tool package deliberately does not import internal/persona/registry (it holds a +// narrow interface instead, keeping the layering one-way), so the composition root +// supplies the display-name lookup. +type sessionReadPersonas struct { + reg *personareg.Registry +} + +// PersonaName implements sessionread.PersonaNamer: the persona's display name, or +// ok=false for an unknown/unregistered role so the tool shows the raw id rather +// than inventing a name. +func (p sessionReadPersonas) PersonaName(id string) (string, bool) { + if p.reg == nil || id == "" { + return "", false + } + persona, ok := p.reg.Persona(id) + if !ok || persona == nil || persona.Name == "" { + return "", false + } + return persona.Name, true +} + +// DefaultPersonaID implements sessionread.PersonaNamer: the persona bound to the +// library's default character, mirroring (*app).defaultPersonaID. An empty id means +// the material renders the explicit "no role set" marker instead of guessing. +func (p sessionReadPersonas) DefaultPersonaID() string { + if p.reg == nil { + return "" + } + if c, ok := p.reg.DefaultCharacter(); ok && c != nil { + return c.PersonaID + } + return "" +} diff --git a/cmd/cyrene/sessionread_tool_test.go b/cmd/cyrene/sessionread_tool_test.go new file mode 100644 index 0000000..0a72675 --- /dev/null +++ b/cmd/cyrene/sessionread_tool_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "testing" + + "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/db" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion" + "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" + personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" +) + +// testConfigWithTimezone builds the minimal config the location lookup reads. +func testConfigWithTimezone(tz string) *config.Config { + return &config.Config{Timezone: tz} +} + +// newTestHistory opens an isolated in-memory chat-history store for the wiring +// tests, mirroring what wire.go hands the composition root. +func newTestHistory(t *testing.T) *history.Store { + t.Helper() + ctx := context.Background() + conn, err := db.Open(ctx, ":memory:", testLog()) + if err != nil { + t.Fatalf("open memory db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + st, err := history.NewStore(ctx, conn, testLog()) + if err != nil { + t.Fatalf("new history store: %v", err) + } + return st +} + +// newTestPersonas registers one explicit role plus the default character, so the +// adapter's two answers (a role's display name and the default role id) are real. +func newTestPersonas(t *testing.T) *personareg.Registry { + t.Helper() + reg := personareg.New() + for _, p := range []*loader.Persona{ + { + ID: "cyrene", Name: "昔涟", + Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5}, + Affection: loader.AffectionParams{Initial: 50, Min: 0, Max: 700, Delta: 10}, + }, + { + ID: "yuki", Name: "雪", + Emotion: loader.EmotionParams{InitialState: emotion.Calm, DecayRate: 0.5}, + Affection: loader.AffectionParams{Initial: 20, Min: 0, Max: 700, Delta: 5}, + }, + } { + if err := reg.RegisterPersona(p); err != nil { + t.Fatalf("RegisterPersona(%s): %v", p.ID, err) + } + } + if err := reg.RegisterCharacter(&personareg.Character{ID: "cyrene", Name: "昔涟", Default: true, PersonaID: "cyrene"}); err != nil { + t.Fatalf("RegisterCharacter(cyrene): %v", err) + } + if err := reg.RegisterCharacter(&personareg.Character{ID: "yuki", Name: "雪", PersonaID: "yuki"}); err != nil { + t.Fatalf("RegisterCharacter(yuki): %v", err) + } + return reg +} + +// TestWireSessionReadToolRegistersOwnerOnly locks the composition-root wiring: the +// cross-session read tool must land in the capability registry AND carry the +// default reachability markers, so a group/stranger session can neither see nor +// call it. That is the auditable gate design/22 §8.1 约束 4 relies on — the tool +// itself writes no audit record. +func TestWireSessionReadToolRegistersOwnerOnly(t *testing.T) { + reg := registry.New() + d, err := tool.New(reg) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + a := &app{log: testLog(), dispatcher: d, history: newTestHistory(t), personas: newTestPersonas(t)} + + a.wireSessionReadTool() + + if _, gerr := reg.Get(sessionread.ToolName); gerr != nil { + t.Fatalf("capability %q is not registered: %v", sessionread.ToolName, gerr) + } + advertised := false + for _, s := range d.ToolSchemas() { + if s.Name == sessionread.ToolName { + advertised = true + } + } + if !advertised { + t.Fatalf("tool schemas %v, want %q advertised", d.ToolSchemas(), sessionread.ToolName) + } + if !restrict.SetContext(reg, restrict.ActorOwner).Allow(sessionread.ToolName) { + t.Fatal("owner was denied the session read tool") + } + if restrict.SetContext(reg, restrict.ActorGroupMember).Allow(sessionread.ToolName) { + t.Fatal("group member may invoke the session read tool; want owner-only") + } + if restrict.SetContext(reg, restrict.ActorStranger).Allow(sessionread.ToolName) { + t.Fatal("stranger may invoke the session read tool; want owner-only") + } + // The registered capability must be the tool itself, not a placeholder. + cap, _ := reg.Get(sessionread.ToolName) + if _, ok := cap.Value.(*sessionread.Tool); !ok { + t.Fatalf("capability value = %T, want *sessionread.Tool", cap.Value) + } +} + +// TestWireSessionReadToolDegradedBoot asserts a boot without a history store or a +// dispatcher logs and skips the registration instead of panicking — the tool must +// never be registered half-wired and then fail on the model's first call. +func TestWireSessionReadToolDegradedBoot(t *testing.T) { + t.Parallel() + reg := registry.New() + d, err := tool.New(reg) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + for name, a := range map[string]*app{ + "no history": {log: testLog(), dispatcher: d}, + "no dispatcher": {log: testLog(), history: newTestHistory(t)}, + } { + t.Run(name, func(t *testing.T) { + a.wireSessionReadTool() + if _, gerr := reg.Get(sessionread.ToolName); gerr == nil { + t.Fatalf("the tool was registered in a degraded boot (%s)", name) + } + }) + } +} + +// TestSessionReadPersonasAdapter locks the adapter the tool's attribution depends +// on: a known role answers its display name, an unknown one is reported unknown +// (so the raw id is shown instead of a wrong name), and the default is the persona +// of the default character. +func TestSessionReadPersonasAdapter(t *testing.T) { + t.Parallel() + adapter := sessionReadPersonas{reg: newTestPersonas(t)} + + if got, ok := adapter.PersonaName("yuki"); !ok || got != "雪" { + t.Fatalf("PersonaName(yuki) = %q, %v; want 雪, true", got, ok) + } + if _, ok := adapter.PersonaName("nobody"); ok { + t.Fatal("PersonaName(nobody) reported a name; want unknown") + } + if got, ok := adapter.PersonaName(""); ok || got != "" { + t.Fatalf("PersonaName(\"\") = %q, %v; want \"\", false", got, ok) + } + if got := adapter.DefaultPersonaID(); got != "cyrene" { + t.Fatalf("DefaultPersonaID() = %q, want cyrene", got) + } + // A nil registry must degrade quietly, not panic: the wiring tolerates a + // persona-less boot. + empty := sessionReadPersonas{} + if _, ok := empty.PersonaName("yuki"); ok || empty.DefaultPersonaID() != "" { + t.Fatal("a nil registry answered a role lookup") + } +} + +// TestSessionReadLocation asserts the configured zone is honored and an +// unresolvable one degrades to nil (the tool's process-local default) instead of +// failing the wiring. +func TestSessionReadLocation(t *testing.T) { + t.Parallel() + a := &app{log: testLog(), cfg: testConfigWithTimezone("Asia/Shanghai")} + if loc := a.sessionReadLocation(testLog()); loc == nil || loc.String() != "Asia/Shanghai" { + t.Fatalf("sessionReadLocation() = %v, want Asia/Shanghai", loc) + } + bad := &app{log: testLog(), cfg: testConfigWithTimezone("Not/AZone")} + if loc := bad.sessionReadLocation(testLog()); loc != nil { + t.Fatalf("sessionReadLocation() = %v, want nil for an unresolvable zone", loc) + } + none := &app{log: testLog()} + if loc := none.sessionReadLocation(testLog()); loc != nil { + t.Fatalf("sessionReadLocation() = %v, want nil without config", loc) + } +} diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index dbc6fa8..678af1b 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -600,6 +600,7 @@ func (a *app) wireAuxiliary() error { a.wireSkills() a.wireTerminalTool() a.wireSubagentTool() + a.wireSessionReadTool() return nil } diff --git a/internal/runtime/sessionread/doc.go b/internal/runtime/sessionread/doc.go new file mode 100644 index 0000000..1446597 --- /dev/null +++ b/internal/runtime/sessionread/doc.go @@ -0,0 +1,21 @@ +// Package sessionread implements the design/22 §8-3「主动读取其他会话上下文」tool: the +// running role may pull another session's recent messages as *external, read-only +// material*, rendered with a fixed attribution header so she can never mistake +// someone else's words for her own memory or her own voice (design/22 §8.1). +// +// Read-only by construction, not by discipline: the package depends on narrow +// interfaces that declare read calls only (HistoryReader, PersonaNamer) and never +// touches internal/persona/registry directly, so no write — memory write, message +// append, read-cursor advance, broadcast — is reachable from inside. It also never +// logs message bodies; only attribution metadata (design/22 §8.1 约束 4). +// +// Access is fail-closed (design/22 §8.1 约束 3): the calling conversation must be +// the owner's, and only the Web main session ("web") and Web sub sessions +// ("web:") may be read. Every other session id — the IM group and stranger +// sessions in particular — is rejected, and an absent session is a hard error +// rather than empty material, so "read nothing" can never pass for "they said +// nothing". +// +// The tool is registered by cmd/cyrene (wireSessionReadTool) and reaches the model +// through the internal/runtime/tool dispatcher like any other tool. +package sessionread diff --git a/internal/runtime/sessionread/format.go b/internal/runtime/sessionread/format.go new file mode 100644 index 0000000..3eb52d1 --- /dev/null +++ b/internal/runtime/sessionread/format.go @@ -0,0 +1,218 @@ +package sessionread + +import ( + "strconv" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" +) + +// Layout of the material (design/22 §8.1 返回文本). The first three lines are the +// hard attribution header: they are present on *every* non-error result, including +// the empty-session one, because their whole purpose is to keep the reading role +// from treating someone else's words as her own memory or her own voice. +const ( + // headerLine is the constant first line of every material. + headerLine = "[外部会话材料 · 只读 · 不属于你的记忆]" + // roleLabelPrefix, sessionLabelPrefix and spanLabelPrefix open the attribution + // header's role line, session line and time-span line. + roleLabelPrefix = "来源角色:" + sessionLabelPrefix = "来源会话:" + spanLabelPrefix = "时间跨度:" + // separatorLine closes the header and opens the transcript. + separatorLine = "————" + // unknownRoleLabel stands in for a role id this runtime cannot name and has no + // default for, so the attribution is never silently dropped. + unknownRoleLabel = "(未设定角色)" + // ownerLabel labels a user message whose sender name is blank — the owner's own + // private chat never carries one (design/16 §4.6). + ownerLabel = "主人" + // systemLabel labels a stored system message, which is neither a role utterance + // nor a user utterance. + systemLabel = "系统" +) + +// Message-line and header timestamp layouts. They mirror the loop's model-facing +// clock (internal/runtime/loop/time.go renders the per-round "当前时间" with the same +// wall-clock layout), so the material reads in the same shape as the runtime's own +// time injection. +const ( + timestampLayout = "2006-01-02 15:04" + timeOnlyLayout = "15:04" +) + +// Layout fragments of the material. +const ( + titleOpen = "「" + titleClose = "」" + roleIDOpen = "(" + roleIDClose = ")" + spanArrow = " → " + noteOpen = "(" + noteClose = ")" + cursorKey = "before=" + cursorInfix = ";更早用 " + cursorKey + earliestNote = ";已到最早" + recentInfix = "最近 " + countSuffix = " 条" + lineOpen = "[" + lineClose = "] " + speakerColon = ":" + // emptyNoMsg is the body of a session that never received a message. + emptyNoMsg = "该会话没有消息。" + // emptyOlderSuffix is the body of a page that came back empty because the + // cursor ran past the oldest message of a session that does have history. + emptyOlderSuffix = " 之前已没有更早的消息。" +) + +// material is one fetched page of an external session, ready to render. msgs is +// newest-first, exactly as the store returns it; the transcript is rendered +// oldest-first. +type material struct { + sessionID string + title string + personaID string + before int64 + msgs []history.Message + // hasOlder reports whether the page filled up, i.e. whether an older page may + // still exist. It is a heuristic (no look-ahead query is issued): a session with + // exactly limit messages also reports true, and the follow-up call then answers + // "no older messages" instead of a transcript. + hasOlder bool +} + +// render builds the material text: the constant attribution header, the time-span +// line when there are messages, and one line per message. +func (r *Reader) render(m material) string { + lines := []string{ + headerLine, + roleLabelPrefix + r.roleLabel(m.personaID), + sessionLabelPrefix + sessionLabel(m.sessionID, m.title), + } + if len(m.msgs) == 0 { + lines = append(lines, separatorLine, emptyBody(m)) + return strings.Join(lines, "\n") + } + lines = append(lines, r.spanLine(m), separatorLine) + for i := len(m.msgs) - 1; i >= 0; i-- { + lines = append(lines, r.messageLine(m.msgs[i])) + } + return strings.Join(lines, "\n") +} + +// emptyBody is the body of an empty material. A session that never received a +// message says so; a page that came back empty because the cursor ran past the +// oldest message says that instead, so the two are never confused. +func emptyBody(m material) string { + if m.before > 0 { + return cursorKey + strconv.FormatInt(m.before, 10) + emptyOlderSuffix + } + return emptyNoMsg +} + +// spanLine renders the time-span line: the oldest and the newest message of the +// page, how many messages the page carries, and the cursor for the next older page +// (or an explicit "already the earliest"). The second timestamp drops the date when +// both ends fall on the same local day. +func (r *Reader) spanLine(m material) string { + oldest, newest := m.msgs[len(m.msgs)-1], m.msgs[0] + span := r.formatAt(oldest.CreatedAt) + spanArrow + r.formatEnd(newest.CreatedAt, oldest.CreatedAt) + return spanLabelPrefix + span + noteOpen + m.scopeNote() + noteClose +} + +// scopeNote is the parenthesized tail of the time-span line: the page size plus +// either the next page's cursor or the explicit "nothing older" marker. +func (m material) scopeNote() string { + note := recentInfix + strconv.Itoa(len(m.msgs)) + countSuffix + if !m.hasOlder { + return note + earliestNote + } + return note + cursorInfix + strconv.FormatInt(m.msgs[len(m.msgs)-1].CreatedAt, 10) +} + +// messageLine renders one transcript line: the message's own timestamp and the +// speaker it is attributed to. The body is copied verbatim — a multi-line message +// therefore keeps its own newlines and is never rewritten or collapsed. +func (r *Reader) messageLine(msg history.Message) string { + return lineOpen + r.formatAt(msg.CreatedAt) + lineClose + r.speakerLabel(msg) + speakerColon + msg.Content +} + +// speakerLabel decides who a message is attributed to. An assistant message is +// attributed by the message's OWN persona_id (design/22 §8.1 约束 5) — never by the +// reading session's role — so a greeting in the middle of a transcript is still +// credited to the role that actually said it. An empty persona id means the global +// default role. A user message uses its stored sender name, falling back to 主人. +func (r *Reader) speakerLabel(msg history.Message) string { + switch msg.Role { + case history.RoleAssistant: + return r.roleName(msg.PersonaID) + case history.RoleSystem: + return systemLabel + default: + if name := strings.TrimSpace(msg.SenderName); name != "" { + return name + } + return ownerLabel + } +} + +// roleName resolves a role id to its display name, mapping an empty id to the +// global default role. An id this runtime cannot name is shown as-is, so an +// unknown role is still visible as a distinct speaker. +func (r *Reader) roleName(id string) string { + if id == "" { + id = r.personas.DefaultPersonaID() + } + if id == "" { + return unknownRoleLabel + } + if name, ok := r.personas.PersonaName(id); ok { + if trimmed := strings.TrimSpace(name); trimmed != "" { + return trimmed + } + } + return id +} + +// roleLabel renders the header's role line: the display name followed by the role +// id in parentheses (design/22 §8.1 "雪(yuki)"). When the id cannot be named, or +// when the name and the id coincide, the name alone is shown. +func (r *Reader) roleLabel(id string) string { + name := r.roleName(id) + resolved := id + if resolved == "" { + resolved = r.personas.DefaultPersonaID() + } + if resolved == "" || resolved == name { + return name + } + return name + roleIDOpen + resolved + roleIDClose +} + +// sessionLabel renders the header's session line: the session id, followed by its +// title in corner brackets when it has one (design/22 §8.1 "web:5f1c…「调试日志」"). +// The id is never truncated: it is what the model needs to page through the very +// same session. +func sessionLabel(sessionID, title string) string { + if trimmed := strings.TrimSpace(title); trimmed != "" { + return sessionID + titleOpen + trimmed + titleClose + } + return sessionID +} + +// formatAt renders a millisecond timestamp in the reader's location. +func (r *Reader) formatAt(ms int64) string { + return time.UnixMilli(ms).In(r.loc).Format(timestampLayout) +} + +// formatEnd renders the time-span line's closing timestamp: date and time when it +// falls on a different local day than the opening one, otherwise the time alone. +func (r *Reader) formatEnd(ms, opening int64) string { + end := time.UnixMilli(ms).In(r.loc) + start := time.UnixMilli(opening).In(r.loc) + if end.Year() == start.Year() && end.YearDay() == start.YearDay() { + return end.Format(timeOnlyLayout) + } + return end.Format(timestampLayout) +} diff --git a/internal/runtime/sessionread/format_test.go b/internal/runtime/sessionread/format_test.go new file mode 100644 index 0000000..88da168 --- /dev/null +++ b/internal/runtime/sessionread/format_test.go @@ -0,0 +1,254 @@ +package sessionread + +import ( + "context" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" +) + +// ownerCtx is the owner's private conversation: the only environment allowed to +// read another session (design/22 §8.1 约束 3). +func ownerCtx() context.Context { + return loop.WithSessionEnv(context.Background(), loop.OwnerEnv()) +} + +// readText runs one session_read call on the owner context and returns the text. +func readText(t *testing.T, tool *Tool, args map[string]any) string { + t.Helper() + out, err := tool.Execute(ownerCtx(), args) + if err != nil { + t.Fatalf("Execute(%v) error = %v", args, err) + } + text, ok := out.(string) + if !ok { + t.Fatalf("Execute returned %T, want a plain string material", out) + } + return text +} + +// mainMessages is the transcript the formatting assertions share, newest first as +// the store returns it: a user turn, an explicit role, a role-less assistant turn +// (the global default) and a named third-party sender. +func mainMessages() []history.Message { + return []history.Message{ + {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "大家好", SenderName: "张三", CreatedAt: at(21, 53)}, + {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "嗯", CreatedAt: at(21, 52)}, + {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "在的", PersonaID: "yuki", CreatedAt: at(21, 7)}, + {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "你好", CreatedAt: at(21, 4)}, + } +} + +// TestExecuteRendersAttributionHeaderAndSpeakers locks the design/22 §8.1 return +// text: the constant first line, the source-role and source-session lines, and one +// attributed line per message in chronological order. The speakers prove the +// attribution rule of §8.1 约束 5 — a message is credited to its OWN persona_id +// (雪), an empty persona_id to the global default role (昔涟), and a user message to +// its sender name (主人 when blank). +func TestExecuteRendersAttributionHeaderAndSpeakers(t *testing.T) { + t.Parallel() + h := &fakeHistory{ + sessions: []history.SessionMeta{{ID: history.MainSessionID, Type: history.SessionMain, Title: "调试日志"}}, + msgs: map[string][]history.Message{history.MainSessionID: mainMessages()}, + } + want := strings.Join([]string{ + "[外部会话材料 · 只读 · 不属于你的记忆]", + "来源角色:昔涟(cyrene)", + "来源会话:web「调试日志」", + "时间跨度:2026-10-03 21:04 → 21:53(最近 4 条;已到最早)", + "————", + "[2026-10-03 21:04] 主人:你好", + "[2026-10-03 21:07] 雪:在的", + "[2026-10-03 21:52] 昔涟:嗯", + "[2026-10-03 21:53] 张三:大家好", + }, "\n") + + if got := readText(t, newReader(h, testPersonas()), map[string]any{"sessionId": "web"}); got != want { + t.Fatalf("material mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// TestMaterialFirstLineIsConstant locks the hardest part of the header: the +// "只读 · 不属于你的记忆" marker is the first line of every material, whether the page +// has messages or not (design/22 §8.1 约束 2). +func TestMaterialFirstLineIsConstant(t *testing.T) { + t.Parallel() + const wantFirst = "[外部会话材料 · 只读 · 不属于你的记忆]" + cases := []struct { + name string + h *fakeHistory + args map[string]any + }{ + { + name: "with messages", + h: &fakeHistory{ + sessions: []history.SessionMeta{{ID: history.MainSessionID, Title: "调试日志"}}, + msgs: map[string][]history.Message{history.MainSessionID: mainMessages()}, + }, + args: map[string]any{"sessionId": "web"}, + }, + { + name: "empty session", + h: &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}}, + args: map[string]any{"sessionId": "web"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + text := readText(t, newReader(tc.h, testPersonas()), tc.args) + if first := strings.SplitN(text, "\n", 2)[0]; first != wantFirst { + t.Fatalf("first line = %q, want %q", first, wantFirst) + } + }) + } +} + +// TestRoleAndSessionAttributionLines locks the two remaining hard header lines: the +// role line names the session's bound role, and the session line carries the id +// (never truncated) plus the title when there is one. +func TestRoleAndSessionAttributionLines(t *testing.T) { + t.Parallel() + const subID = "web:5f1c8d2e" + h := &fakeHistory{ + sessions: []history.SessionMeta{{ID: subID, Type: history.SessionSub, Title: "调试日志", PersonaID: "yuki"}}, + msgs: map[string][]history.Message{subID: {{SessionID: subID, Role: history.RoleAssistant, Content: "在", CreatedAt: at(21, 4)}}}, + } + lines := strings.Split(readText(t, newReader(h, testPersonas()), map[string]any{"sessionId": subID}), "\n") + want := []string{"来源角色:雪(yuki)", "来源会话:" + subID + "「调试日志」"} + for i, w := range want { + if lines[i+1] != w { + t.Fatalf("header line %d = %q, want %q", i+2, lines[i+1], w) + } + } +} + +// TestSessionLineOmitsAbsentTitle asserts a title-less session renders as the bare +// id rather than empty corner brackets. +func TestSessionLineOmitsAbsentTitle(t *testing.T) { + t.Parallel() + h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}} + text := readText(t, newReader(h, testPersonas()), map[string]any{"sessionId": "web"}) + if !strings.Contains(text, "\n来源会话:web\n") { + t.Fatalf("material = %q, want the bare session id line", text) + } +} + +// TestSpeakerFallbacks locks the attribution fallbacks of §8.1 约束 5: an unknown +// role id is shown as-is (never dropped), a system message is labelled 系统, and a +// blank sender name on a user message becomes 主人. +func TestSpeakerFallbacks(t *testing.T) { + t.Parallel() + msgs := []history.Message{ + {SessionID: history.MainSessionID, Role: history.RoleSystem, Content: "sys", CreatedAt: at(21, 6)}, + {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "hi", PersonaID: "nobody", CreatedAt: at(21, 5)}, + {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "你好", CreatedAt: at(21, 4)}, + } + h := &fakeHistory{ + sessions: []history.SessionMeta{{ID: history.MainSessionID}}, + msgs: map[string][]history.Message{history.MainSessionID: msgs}, + } + want := strings.Join([]string{ + "[2026-10-03 21:04] 主人:你好", + "[2026-10-03 21:05] nobody:hi", + "[2026-10-03 21:06] 系统:sys", + }, "\n") + if got := readText(t, newReader(h, testPersonas()), map[string]any{"sessionId": "web"}); !strings.Contains(got, want) { + t.Fatalf("material = %q, want it to contain\n%s", got, want) + } +} + +// TestRoleWithoutNamerShowsRawIDs asserts the degraded boot: with no persona namer +// injected the material still attributes speakers, by raw role id and the +// explicit "角色未设定" marker, instead of inventing or dropping a name. +func TestRoleWithoutNamerShowsRawIDs(t *testing.T) { + t.Parallel() + h := &fakeHistory{ + sessions: []history.SessionMeta{{ID: history.MainSessionID, PersonaID: "yuki"}}, + msgs: map[string][]history.Message{history.MainSessionID: { + {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "在的", PersonaID: "yuki", CreatedAt: at(21, 4)}, + {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "嗯", CreatedAt: at(21, 3)}, + }}, + } + text := readText(t, newReader(h, nil), map[string]any{"sessionId": "web"}) + for _, want := range []string{"\n来源角色:yuki\n", "[2026-10-03 21:03] (未设定角色):嗯\n", "\n[2026-10-03 21:04] yuki:在的"} { + if !strings.Contains(text, want) { + t.Fatalf("material = %q, want it to contain %q", text, want) + } + } +} + +// TestSpanLineCrossDayKeepsTheDate asserts the time-span line drops the date on its +// right-hand side only when both ends fall on the same local day — a page spanning +// midnight must not read as if it were one evening. +func TestSpanLineCrossDayKeepsTheDate(t *testing.T) { + t.Parallel() + msgs := []history.Message{ + {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "b", CreatedAt: at(0, 30)}, + {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "a", CreatedAt: time.Date(2026, 10, 2, 23, 50, 0, 0, testLocation).UnixMilli()}, + } + h := &fakeHistory{ + sessions: []history.SessionMeta{{ID: history.MainSessionID}}, + msgs: map[string][]history.Message{history.MainSessionID: msgs}, + } + text := readText(t, newReader(h, testPersonas()), map[string]any{"sessionId": "web"}) + if !strings.Contains(text, "时间跨度:2026-10-02 23:50 → 2026-10-03 00:30") { + t.Fatalf("material = %q, want a fully dated span", text) + } +} + +// TestSpanLineOffersNextCursor asserts the fourth line carries the cursor for the +// next older page — the oldest timestamp of the current page, matching the +// "before" semantics of GET /sessions/{id}/messages. +func TestSpanLineOffersNextCursor(t *testing.T) { + t.Parallel() + msgs := []history.Message{ + {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "c", PersonaID: "yuki", CreatedAt: at(21, 52)}, + {SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "b", PersonaID: "yuki", CreatedAt: at(21, 7)}, + {SessionID: history.MainSessionID, Role: history.RoleUser, Content: "a", CreatedAt: at(21, 4)}, + } + h := &fakeHistory{ + sessions: []history.SessionMeta{{ID: history.MainSessionID}}, + msgs: map[string][]history.Message{history.MainSessionID: msgs}, + } + text := readText(t, newReader(h, testPersonas()), map[string]any{"sessionId": "web", "limit": 2}) + if !strings.Contains(text, "时间跨度:2026-10-03 21:07 → 21:52(最近 2 条;更早用 before=") { + t.Fatalf("material = %q, want the next-page cursor", text) + } + if !strings.Contains(text, "before="+itoa(at(21, 7))+")") { + t.Fatalf("material = %q, want the cursor to be the page's oldest timestamp", text) + } + if strings.Contains(text, "[2026-10-03 21:04] 主人:a") { + t.Fatalf("material = %q, want only the newest page", text) + } +} + +// TestEmptyMaterials says exactly why a page is empty: a session with no history at +// all versus a cursor that ran past the oldest message of a session that does have +// one. Neither is an error, and neither may read as "they said nothing" about the +// wrong thing (design/22 §8.1 约束 3). +func TestEmptyMaterials(t *testing.T) { + t.Parallel() + const silentID = "web:5f1c-silent" + h := &fakeHistory{ + sessions: []history.SessionMeta{ + {ID: history.MainSessionID}, + {ID: silentID, Type: history.SessionSub}, + }, + msgs: map[string][]history.Message{history.MainSessionID: {{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "a", CreatedAt: at(21, 4)}}}, + } + tool := newReader(h, testPersonas()) + + if got := readText(t, tool, map[string]any{"sessionId": silentID}); !strings.HasSuffix(got, silentID+"\n————\n该会话没有消息。") { + t.Fatalf("empty material = %q, want the explicit \"该会话没有消息\" body", got) + } + gone := readText(t, tool, map[string]any{"sessionId": "web", "before": at(21, 4)}) + if !strings.HasSuffix(gone, "before="+itoa(at(21, 4))+" 之前已没有更早的消息。") { + t.Fatalf("past-the-end material = %q, want the explicit cursor notice", gone) + } + if strings.Contains(gone, "该会话没有消息") { + t.Fatalf("past-the-end material = %q, must not claim the session is empty", gone) + } +} diff --git a/internal/runtime/sessionread/helpers_test.go b/internal/runtime/sessionread/helpers_test.go new file mode 100644 index 0000000..0ff5df0 --- /dev/null +++ b/internal/runtime/sessionread/helpers_test.go @@ -0,0 +1,131 @@ +package sessionread + +import ( + "context" + "database/sql" + "io" + "log/slog" + "strconv" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" +) + +// testLogger returns a logger that writes nowhere, keeping test output clean. +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// testLocation pins the rendering timezone so a timestamp assertion is stable +// regardless of the machine's TZ. +var testLocation = time.UTC + +// at builds a millisecond timestamp from the readable wall clock the assertions +// use, so an expected line is written the same way a reader thinks about it. +func at(hour, minute int) int64 { + return time.Date(2026, 10, 3, hour, minute, 0, 0, testLocation).UnixMilli() +} + +// itoa renders a millisecond cursor the way the material does. +func itoa(ms int64) string { return strconv.FormatInt(ms, 10) } + +// newTestStore opens an isolated in-memory history store. The *sql.DB comes back +// too so a read-only assertion can inspect the tables directly. +func newTestStore(t *testing.T) (*history.Store, *sql.DB) { + t.Helper() + ctx := context.Background() + conn, err := db.Open(ctx, ":memory:", testLogger()) + if err != nil { + t.Fatalf("open memory db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + st, err := history.NewStore(ctx, conn, testLogger()) + if err != nil { + t.Fatalf("new store: %v", err) + } + return st, conn +} + +// testPersonas is the role-name double: it knows two roles and one default. +func testPersonas() PersonaNamer { + return fakePersonas{ + names: map[string]string{"yuki": "雪", "cyrene": "昔涟"}, + def: "cyrene", + } +} + +// fakePersonas is a read-only PersonaNamer with a configurable map. +type fakePersonas struct { + names map[string]string + def string +} + +// PersonaName implements PersonaNamer. +func (f fakePersonas) PersonaName(id string) (string, bool) { + name, ok := f.names[id] + return name, ok +} + +// DefaultPersonaID implements PersonaNamer. +func (f fakePersonas) DefaultPersonaID() string { return f.def } + +// fakeHistory is the read-only test double: it implements exactly the two read +// calls the package depends on and nothing else. There is no append, no delete and +// no read-cursor method for the code under test to reach, which is how the +// read-only guarantee of design/22 §8.1 约束 1 becomes a type-level property +// instead of a promise (see TestReaderExposesNoWriteMethod). +type fakeHistory struct { + sessions []history.SessionMeta + msgs map[string][]history.Message // newest first, like the store + listErr error + sessionsErr error + + // observed arguments of the last List call, for the pagination assertions. + listBefore int64 + listLimit int + listCalls int +} + +// List implements HistoryReader with the store's semantics: newest first, strictly +// older than before, at most limit rows. +func (f *fakeHistory) List(_ context.Context, sessionID string, before int64, limit int, _ string) ([]history.Message, error) { + f.listBefore, f.listLimit = before, limit + f.listCalls++ + if f.listErr != nil { + return nil, f.listErr + } + out := make([]history.Message, 0, limit) + for _, m := range f.msgs[sessionID] { + if before > 0 && m.CreatedAt >= before { + continue + } + if len(out) == limit { + break + } + out = append(out, m) + } + return out, nil +} + +// ListSessions implements HistoryReader. +func (f *fakeHistory) ListSessions(_ context.Context) ([]history.SessionMeta, error) { + if f.sessionsErr != nil { + return nil, f.sessionsErr + } + return f.sessions, nil +} + +// Compile-time assertions: the real store must satisfy the read-only view this +// package holds (the composition root injects it), and the read-only double must +// satisfy it too. +var ( + _ HistoryReader = (*history.Store)(nil) + _ HistoryReader = (*fakeHistory)(nil) +) + +// newReader builds a Reader over h with the test doubles the assertions expect. +func newReader(h HistoryReader, p PersonaNamer) *Tool { + return New(WithHistory(h), WithPersonas(p), WithLocation(testLocation), WithLogger(testLogger())) +} diff --git a/internal/runtime/sessionread/reader.go b/internal/runtime/sessionread/reader.go new file mode 100644 index 0000000..7ad3dac --- /dev/null +++ b/internal/runtime/sessionread/reader.go @@ -0,0 +1,195 @@ +package sessionread + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" +) + +// Page bounds of the read. They match the REST page (GET /sessions/{id}/messages, +// history.clampLimit) so a tool page and a client page are the same size, with the +// default and the cap applied here rather than rejected as bad arguments. +const ( + DefaultLimit = 50 + MaxLimit = 200 +) + +// Sentinels distinguishing the fail-closed refusals from a genuine failure. Each +// case has its own message so the model — and the operator reading the log — can +// tell "not allowed" from "does not exist", which design/22 §8.1 约束 3 requires. +var ( + // ErrNoHistory is returned when no read-only history view was injected. + ErrNoHistory = errors.New("sessionread: no session-history reader injected") + + // ErrNotOwner is returned when the calling conversation is not the owner's. + ErrNotOwner = errors.New("sessionread: only the owner conversation may read another session") + + // ErrSessionNotAllowed is returned when the target session id is outside the + // readable whitelist (Web main session and Web sub sessions only). + ErrSessionNotAllowed = errors.New("sessionread: target session is not readable (only web / web:)") + + // ErrSessionNotFound is returned when the target session does not exist. It is + // deliberately an error, never empty material: "read nothing" must not be + // mistakable for "they said nothing". + ErrSessionNotFound = errors.New("sessionread: target session does not exist") +) + +// HistoryReader is the read-only session-history view this package needs. It is +// deliberately narrower than history.HistoryStore: it declares no append, no +// delete and no read-cursor call, so from inside this package a message cannot be +// written to any session and the target session's read_at cannot be advanced +// (design/22 §8.1 约束 1). +type HistoryReader interface { + // List returns up to limit messages of sessionID, newest first, bounded to the + // messages strictly older than before when before > 0 (design/16 §3.1). + List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error) + // ListSessions returns every session's metadata (id, title, role). + ListSessions(ctx context.Context) ([]history.SessionMeta, error) +} + +// PersonaNamer resolves role ids to display names. The package keeps this a narrow +// interface on purpose: importing internal/persona/registry here would couple the +// runtime to the persona loader, so the composition root supplies an adapter. +type PersonaNamer interface { + // PersonaName returns the display name of role id. ok is false for a role this + // runtime does not know, in which case the raw id is shown instead of dropping + // the attribution. + PersonaName(id string) (string, bool) + // DefaultPersonaID returns the global default role id, or "" when none is set. + // An unset role id on a message or session means "the default role". + DefaultPersonaID() string +} + +// noPersonaNames is the fallback namer: it knows no role names, so the material +// shows raw ids rather than inventing a display name. +type noPersonaNames struct{} + +// PersonaName implements PersonaNamer. +func (noPersonaNames) PersonaName(string) (string, bool) { return "", false } + +// DefaultPersonaID implements PersonaNamer. +func (noPersonaNames) DefaultPersonaID() string { return "" } + +// Request is one session-read request after argument decoding. +type Request struct { + // SessionID is the target session ("web" or "web:"). + SessionID string + // Limit caps the page; 0 or less means DefaultLimit, above MaxLimit is clamped. + Limit int + // Before is the millisecond cursor: only messages strictly older than it are + // returned. 0 or less means the newest page. + Before int64 +} + +// Reader performs the fail-closed authorization and the read, then renders the +// material. It holds no per-call state and is safe for concurrent use. +type Reader struct { + history HistoryReader + personas PersonaNamer + loc *time.Location + log *slog.Logger +} + +// Read authorizes the call, reads one page of the target session and returns the +// formatted external material. +// +// The checks run in this order deliberately: the caller's environment first (a +// non-owner conversation learns nothing about any session), the id whitelist +// second (a session outside web/web: is refused even if it exists), and only +// then the existence lookup. +func (r *Reader) Read(ctx context.Context, req Request) (string, error) { + if r.history == nil { + return "", ErrNoHistory + } + env := loop.SessionEnvFrom(ctx) + if err := r.authorize(env, req.SessionID); err != nil { + return "", err + } + meta, err := r.sessionMeta(ctx, req.SessionID) + if err != nil { + return "", err + } + limit := clampLimit(req.Limit) + msgs, err := r.history.List(ctx, req.SessionID, req.Before, limit, "") + if err != nil { + return "", fmt.Errorf("sessionread: list %s: %w", req.SessionID, err) + } + m := material{ + sessionID: req.SessionID, + title: meta.Title, + personaID: meta.PersonaID, + before: req.Before, + msgs: msgs, + hasOlder: len(msgs) == limit, + } + // Who read what, and how much — the provenance of design/22 §8.1 约束 4 — is + // logged here; the message bodies are not. The audit record itself stays with the + // loop's gate/OnAudit, which this tool neither writes to nor bypasses. + r.log.Info("external session material read", + "actor", string(env.ActorEnv), "target", req.SessionID, "count", len(msgs), + "limit", limit, "before", req.Before, "has_older", m.hasOlder) + return r.render(m), nil +} + +// authorize enforces the fail-closed whitelist: the calling conversation must be +// the owner's, and the target must be the Web main session or a Web sub session. +// Default deny, never default allow (design/22 §8.1 约束 3). +func (r *Reader) authorize(env loop.SessionEnv, sessionID string) error { + if env.ActorEnv != restrict.ActorOwner { + r.log.Warn("external session read refused", "target", sessionID, "env", string(env.ActorEnv)) + return ErrNotOwner + } + if !ReadableSessionID(sessionID) { + r.log.Warn("external session read refused", "target", sessionID, "reason", "session not readable") + return ErrSessionNotAllowed + } + return nil +} + +// ReadableSessionID reports whether id is in the readable set: the Web main session +// or a Web sub session. It is the fail-closed whitelist of design/22 §8.1 约束 3 — +// IM group and stranger sessions live outside it and are exactly the ones that must +// never leak. +func ReadableSessionID(id string) bool { + if id == history.MainSessionID { + return true + } + return strings.HasPrefix(id, history.MainSessionID+":") && len(id) > len(history.MainSessionID)+1 +} + +// sessionMeta looks the session up in the session list, so a session that does not +// exist is a hard error rather than empty material. The list read is a single +// parameter-free query the store already serves for the session list page. +func (r *Reader) sessionMeta(ctx context.Context, sessionID string) (history.SessionMeta, error) { + sessions, err := r.history.ListSessions(ctx) + if err != nil { + return history.SessionMeta{}, fmt.Errorf("sessionread: list sessions: %w", err) + } + for _, meta := range sessions { + if meta.ID == sessionID { + return meta, nil + } + } + return history.SessionMeta{}, fmt.Errorf("sessionread: session %q: %w", sessionID, ErrSessionNotFound) +} + +// clampLimit bounds a requested page size to [DefaultLimit, MaxLimit], mirroring +// history.clampLimit so a tool page and a client page are the same size. A +// non-positive request (an absent argument, a non-numeric one, or a negative one) +// means DefaultLimit; a request above the cap is clamped, not rejected. +func clampLimit(limit int) int { + if limit < 1 { + return DefaultLimit + } + if limit > MaxLimit { + return MaxLimit + } + return limit +} diff --git a/internal/runtime/sessionread/reader_test.go b/internal/runtime/sessionread/reader_test.go new file mode 100644 index 0000000..004cfda --- /dev/null +++ b/internal/runtime/sessionread/reader_test.go @@ -0,0 +1,312 @@ +package sessionread + +import ( + "context" + "database/sql" + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" +) + +// envCtx builds a context carrying an explicit actor environment, the way the +// gateway and channel bridge attach one per turn. +func envCtx(env restrict.ActorEnv) context.Context { + return loop.WithSessionEnv(context.Background(), loop.SessionEnv{ActorEnv: env, MemWritable: env == restrict.ActorOwner}) +} + +// TestNonOwnerIsRefused locks the first half of the fail-closed whitelist: a group +// or stranger conversation may not read another session at all, and the refusal +// happens before any read — nothing about the target leaks (design/22 §8.1 约束 3). +func TestNonOwnerIsRefused(t *testing.T) { + t.Parallel() + for _, env := range []restrict.ActorEnv{restrict.ActorGroupMember, restrict.ActorStranger} { + t.Run(string(env), func(t *testing.T) { + h := &fakeHistory{ + sessions: []history.SessionMeta{{ID: history.MainSessionID}}, + msgs: map[string][]history.Message{history.MainSessionID: mainMessages()}, + } + tool := newReader(h, testPersonas()) + _, err := tool.Execute(envCtx(env), map[string]any{"sessionId": history.MainSessionID}) + if !errors.Is(err, ErrNotOwner) { + t.Fatalf("err = %v, want ErrNotOwner", err) + } + if h.listCalls != 0 { + t.Fatalf("history was read %d times before the refusal, want 0", h.listCalls) + } + // The refusal must be distinguishable from "the session does not exist". + if strings.Contains(err.Error(), ErrSessionNotFound.Error()) { + t.Fatalf("err = %v, a refusal must not read like a missing session", err) + } + }) + } +} + +// TestReadableSessionID is the whitelist table: only the Web main session and Web +// sub sessions are readable. In particular "web:" with no suffix, a bare prefix +// look-alike and every IM session id are refused (design/22 §8.1 约束 3). +func TestReadableSessionID(t *testing.T) { + t.Parallel() + cases := []struct { + id string + want bool + }{ + {history.MainSessionID, true}, + {"web:5f1c8d2e-0000-1111-2222-333344445555", true}, + {"web:", false}, + {"web", true}, + {"webx", false}, + {"webs", false}, + {"telegram:-100200300", false}, + {"kook:3bef", false}, + {"sess-group", false}, + {"", false}, + } + for _, tc := range cases { + if got := ReadableSessionID(tc.id); got != tc.want { + t.Errorf("ReadableSessionID(%q) = %v, want %v", tc.id, got, tc.want) + } + } +} + +// TestUnreadableSessionIsRefused locks the other half of the whitelist: an id +// outside web/web: is refused even though it might exist, so an IM group or +// stranger session can never be reached through this tool. +func TestUnreadableSessionIsRefused(t *testing.T) { + t.Parallel() + h := &fakeHistory{ + sessions: []history.SessionMeta{{ID: "telegram:-100200300", Title: "群聊"}}, + msgs: map[string][]history.Message{"telegram:-100200300": {{Role: history.RoleUser, Content: "秘密", CreatedAt: at(21, 4)}}}, + } + tool := newReader(h, testPersonas()) + _, err := tool.Execute(ownerCtx(), map[string]any{"sessionId": "telegram:-100200300"}) + if !errors.Is(err, ErrSessionNotAllowed) { + t.Fatalf("err = %v, want ErrSessionNotAllowed", err) + } + if h.listCalls != 0 { + t.Fatalf("history was read %d times before the refusal, want 0", h.listCalls) + } +} + +// TestMissingSessionIsAnErrorNotEmptyMaterial locks design/22 §8.1 约束 3: a session +// that does not exist is a hard, clearly labelled error, never empty material — +// otherwise "read nothing" could be taken for "they said nothing". +func TestMissingSessionIsAnErrorNotEmptyMaterial(t *testing.T) { + t.Parallel() + h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}} + tool := newReader(h, testPersonas()) + out, err := tool.Execute(ownerCtx(), map[string]any{"sessionId": "web:missing"}) + if !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("err = %v, want ErrSessionNotFound", err) + } + if out != nil { + t.Fatalf("out = %#v, want nil alongside the error (no empty material)", out) + } + for _, other := range []error{ErrSessionNotAllowed, ErrNotOwner} { + if errors.Is(err, other) { + t.Fatalf("err = %v, must stay distinguishable from %v", err, other) + } + } + if !strings.Contains(err.Error(), "session") { + t.Fatalf("err = %v, want a human-readable missing-session message", err) + } +} + +// TestMissingArgumentIsRejected asserts the tool refuses a call without a target +// instead of silently reading the main session. +func TestMissingArgumentIsRejected(t *testing.T) { + t.Parallel() + h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}} + tool := newReader(h, testPersonas()) + for _, args := range []any{map[string]any{}, map[string]any{"sessionId": " "}, "not-an-object"} { + if _, err := tool.Execute(ownerCtx(), args); err == nil { + t.Fatalf("Execute(%#v) accepted the call, want an error", args) + } + } + if h.listCalls != 0 { + t.Fatalf("history was read %d times for argument-less calls, want 0", h.listCalls) + } +} + +// TestLimitIsClampedNotRejected locks the page bounds: an absent, non-numeric or +// negative limit means the default 50, and a limit above the cap is clamped to 200 +// (mirroring GET /sessions/{id}/messages) rather than refused as a bad argument. +func TestLimitIsClampedNotRejected(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args map[string]any + want int + }{ + {"absent", map[string]any{"sessionId": history.MainSessionID}, DefaultLimit}, + {"zero", map[string]any{"sessionId": history.MainSessionID, "limit": 0}, DefaultLimit}, + {"negative", map[string]any{"sessionId": history.MainSessionID, "limit": -3}, DefaultLimit}, + {"non-numeric", map[string]any{"sessionId": history.MainSessionID, "limit": "abc"}, DefaultLimit}, + {"above-cap", map[string]any{"sessionId": history.MainSessionID, "limit": 5000}, MaxLimit}, + {"in-range", map[string]any{"sessionId": history.MainSessionID, "limit": 7}, 7}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := &fakeHistory{sessions: []history.SessionMeta{{ID: history.MainSessionID}}} + readText(t, newReader(h, testPersonas()), tc.args) + if h.listLimit != tc.want { + t.Fatalf("List limit = %d, want %d", h.listLimit, tc.want) + } + }) + } +} + +// TestBeforePagesStrictlyOlder is the end-to-end pagination check against the real +// store: the first page is the newest window, and before= returns only strictly older messages — the same semantics as the REST +// endpoint, so a client cursor and a tool cursor mean the same thing. +func TestBeforePagesStrictlyOlder(t *testing.T) { + t.Parallel() + st, _ := newTestStore(t) + ctx := context.Background() + for i := 1; i <= 5; i++ { + if err := st.Append(ctx, history.Message{ + SessionID: history.MainSessionID, Role: history.RoleUser, + Content: fmt.Sprintf("m%d", i), CreatedAt: at(21, i), + }); err != nil { + t.Fatalf("append m%d: %v", i, err) + } + } + tool := newReader(st, testPersonas()) + + first := readText(t, tool, map[string]any{"sessionId": history.MainSessionID, "limit": 2}) + for _, want := range []string{"m5", "m4"} { + if !strings.Contains(first, want) { + t.Fatalf("first page = %q, want it to contain %q", first, want) + } + } + if strings.Contains(first, "m3") || !strings.Contains(first, "before="+itoa(at(21, 4))) { + t.Fatalf("first page = %q, want only m5/m4 plus the cursor for m4's timestamp", first) + } + + older := readText(t, tool, map[string]any{"sessionId": history.MainSessionID, "limit": 2, "before": at(21, 4)}) + if strings.Contains(older, "m5") || strings.Contains(older, "m4") { + t.Fatalf("second page = %q, want strictly older messages only", older) + } + for _, want := range []string{"m3", "m2"} { + if !strings.Contains(older, want) { + t.Fatalf("second page = %q, want it to contain %q", older, want) + } + } +} + +// queryInt runs a one-value, one-int query (test helper). +func queryInt(t *testing.T, conn *sql.DB, query string, args ...any) int64 { + t.Helper() + var n int64 + if err := conn.QueryRow(query, args...).Scan(&n); err != nil { + t.Fatalf("query %q: %v", query, err) + } + return n +} + +// TestReadIsReadOnly is the end-to-end read-only proof of design/22 §8.1 约束 1 +// against the real store: reading another session writes nothing — no message is +// appended to either session, no session is created or deleted, and the target's +// read cursor is not advanced (UnreadCount is unchanged). +func TestReadIsReadOnly(t *testing.T) { + t.Parallel() + st, conn := newTestStore(t) + ctx := context.Background() + subID, err := st.NewSubSessionFor(ctx, "owner", "yuki") + if err != nil { + t.Fatalf("new sub session: %v", err) + } + for i := 1; i <= 3; i++ { + if err := st.Append(ctx, history.Message{ + SessionID: subID, Role: history.RoleAssistant, PersonaID: "yuki", + Content: fmt.Sprintf("sub%d", i), CreatedAt: at(21, i), + }); err != nil { + t.Fatalf("append sub%d: %v", i, err) + } + } + beforeReadAt := queryInt(t, conn, `SELECT read_at FROM chat_sessions WHERE id = ?`, subID) + beforeMsgs := queryInt(t, conn, `SELECT COUNT(*) FROM chat_messages WHERE session_id = ?`, subID) + beforeSessions := queryInt(t, conn, `SELECT COUNT(*) FROM chat_sessions`) + + tool := newReader(st, testPersonas()) + if got := readText(t, tool, map[string]any{"sessionId": subID}); !strings.Contains(got, "sub1") { + t.Fatalf("material = %q, want the sub session transcript", got) + } + + if after := queryInt(t, conn, `SELECT read_at FROM chat_sessions WHERE id = ?`, subID); after != beforeReadAt { + t.Fatalf("read_at = %d after the read, want it unchanged at %d", after, beforeReadAt) + } + if after := queryInt(t, conn, `SELECT COUNT(*) FROM chat_messages WHERE session_id = ?`, subID); after != beforeMsgs { + t.Fatalf("sub session message count = %d, want unchanged at %d", after, beforeMsgs) + } + if after := queryInt(t, conn, `SELECT COUNT(*) FROM chat_messages WHERE session_id = ?`, history.MainSessionID); after != 0 { + t.Fatalf("main session gained %d messages, want the material to stay out of the reading session's history", after) + } + if after := queryInt(t, conn, `SELECT COUNT(*) FROM chat_sessions`); after != beforeSessions { + t.Fatalf("session count = %d, want unchanged at %d", after, beforeSessions) + } +} + +// TestReaderExposesNoWriteMethod is the type-level half of the read-only guarantee: +// the interfaces this package depends on declare no write verb at all, so there is +// no Append/Delete/Rename/Mark/Publish method for any code path here to call. A +// future edit that widened HistoryReader with a write call would fail this test. +func TestReaderExposesNoWriteMethod(t *testing.T) { + t.Parallel() + writeVerbs := []string{"Append", "Delete", "Rename", "Mark", "Set", "Update", "Publish", "Write", "Broadcast", "Save", "Insert"} + for _, iface := range []reflect.Type{ + reflect.TypeOf((*HistoryReader)(nil)).Elem(), + reflect.TypeOf((*PersonaNamer)(nil)).Elem(), + } { + methods := make([]string, 0, iface.NumMethod()) + for i := 0; i < iface.NumMethod(); i++ { + methods = append(methods, iface.Method(i).Name) + } + if len(methods) == 0 { + t.Fatalf("%s declares no method at all", iface) + } + for _, name := range methods { + for _, verb := range writeVerbs { + if strings.HasPrefix(name, verb) { + t.Errorf("%s.%s looks like a write; this package must depend on reads only", iface, name) + } + } + } + } +} + +// TestHistoryFailureIsWrapped asserts a store failure surfaces as an error wrapping +// the cause instead of being rendered as empty material. +func TestHistoryFailureIsWrapped(t *testing.T) { + t.Parallel() + sentinel := errors.New("boom") + h := &fakeHistory{ + sessions: []history.SessionMeta{{ID: history.MainSessionID}}, + listErr: sentinel, + } + _, err := newReader(h, testPersonas()).Execute(ownerCtx(), map[string]any{"sessionId": history.MainSessionID}) + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want it to wrap %v", err, sentinel) + } + + sessions := &fakeHistory{sessionsErr: sentinel} + if _, err := newReader(sessions, testPersonas()).Execute(ownerCtx(), map[string]any{"sessionId": history.MainSessionID}); !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want it to wrap %v", err, sentinel) + } +} + +// TestNoHistoryInjected asserts a tool built without a history reader fails fast +// rather than fabricating material. +func TestNoHistoryInjected(t *testing.T) { + t.Parallel() + _, err := New(WithPersonas(testPersonas())).Execute(ownerCtx(), map[string]any{"sessionId": history.MainSessionID}) + if !errors.Is(err, ErrNoHistory) { + t.Fatalf("err = %v, want ErrNoHistory", err) + } +} diff --git a/internal/runtime/sessionread/tool.go b/internal/runtime/sessionread/tool.go new file mode 100644 index 0000000..e241ab5 --- /dev/null +++ b/internal/runtime/sessionread/tool.go @@ -0,0 +1,180 @@ +package sessionread + +import ( + "context" + "fmt" + "io" + "log/slog" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// ToolName is the registered tool name the model calls to read another session's +// context (design/22 §8.1 工具形态). +const ToolName = "session_read" + +// Compile-time assertion that Tool implements sdk.Tool. +var _ sdk.Tool = (*Tool)(nil) + +// Tool is the agent-facing shell of the cross-session read: it parses the tool +// arguments, delegates to Reader for the fail-closed authorization and the read, +// and hands the model the formatted material. +// +// It is safe for concurrent use and holds no per-call state. +type Tool struct { + reader *Reader +} + +// Option customizes the Tool (and the Reader behind it) built by New. +type Option func(*Reader) + +// WithHistory injects the read-only history view the material is read from. +// *history.Store satisfies it. Without it every call fails with ErrNoHistory. +func WithHistory(h HistoryReader) Option { + return func(r *Reader) { r.history = h } +} + +// WithPersonas injects the role-name lookup used for the attribution labels. +// Without it the material shows raw role ids (no name is invented). +func WithPersonas(p PersonaNamer) Option { + return func(r *Reader) { r.personas = p } +} + +// WithLocation sets the timezone the timestamps are rendered in. A nil location +// keeps time.Local, matching the process-wide convention. The composition root +// passes the configured TZ so the material reads in the same clock as the loop's +// per-round "current time" injection. +func WithLocation(loc *time.Location) Option { + return func(r *Reader) { + if loc != nil { + r.loc = loc + } + } +} + +// WithLogger replaces the reader's logger (tagged source "runtime/sessionread"). +func WithLogger(l *slog.Logger) Option { + return func(r *Reader) { r.log = sourceLog(l) } +} + +// New builds the tool over the injected dependencies. A tool built without a +// history reader still constructs (the composition root can log and refuse to +// register it); every call then fails with ErrNoHistory. +func New(opts ...Option) *Tool { + r := &Reader{loc: time.Local, log: sourceLog(nil)} + for _, opt := range opts { + if opt != nil { + opt(r) + } + } + if r.personas == nil { + r.personas = noPersonaNames{} + } + return &Tool{reader: r} +} + +// Name returns the tool identifier. +func (t *Tool) Name() string { return ToolName } + +// Description explains the tool to the model: it reads someone else's session as +// *external, read-only material* that is explicitly not the caller's own memory or +// voice (design/22 §8-3 的原意), reachable only for the Web sessions. +func (t *Tool) Description() string { + return "Read another session's recent messages as external, read-only material. " + + "The result is a transcript prefixed with a fixed attribution header naming the source role " + + "and source session, so it is explicitly NOT your own memory and not your own words — never " + + "quote or continue it as if you had said it. Only the Web main session (\"web\") and Web sub " + + "sessions (\"web:\") can be read, only from the owner's conversation; the target " + + "session's read cursor is not advanced and nothing is written." +} + +// Parameters returns the input JSON Schema (design/22 §8.1 参数表). No "maximum" +// bound is declared on limit on purpose: a limit above the cap is clamped in +// Execute (mirroring GET /sessions/{id}/messages) instead of being rejected as an +// invalid argument. +func (t *Tool) Parameters() (map[string]any, error) { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "sessionId": map[string]any{ + "type": "string", + "description": "Target session id to read: the Web main session \"web\" or a Web sub " + + "session \"web:\". Any other session id is rejected.", + }, + "limit": map[string]any{ + "type": "integer", + "description": "Max messages to return (optional, default 50, capped at 200).", + }, + "before": map[string]any{ + "type": "integer", + "description": "Millisecond cursor: return only messages older than it, to page further " + + "back (optional; omit for the newest page).", + }, + }, + "required": []any{"sessionId"}, + }, nil +} + +// Execute reads the requested session and returns the formatted material. +// +// The return value is a plain string, not a struct: the loop renders a string tool +// result verbatim (loop.formatToolResult passes strings through, while any other +// value is JSON-encoded and would escape every newline), and the material is +// line-oriented text whose attribution header must survive intact. +func (t *Tool) Execute(ctx context.Context, args any) (any, error) { + m, ok := args.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: args must be an object", ToolName) + } + sessionID := strings.TrimSpace(stringArg(m, "sessionId")) + if sessionID == "" { + return nil, fmt.Errorf("%s: sessionId is required", ToolName) + } + text, err := t.reader.Read(ctx, Request{ + SessionID: sessionID, + Limit: intArg(m, "limit"), + Before: int64(intArg(m, "before")), + }) + if err != nil { + return nil, fmt.Errorf("%s: %w", ToolName, err) + } + return text, nil +} + +// stringArg reads a string argument, tolerating a missing or non-string value. +func stringArg(m map[string]any, key string) string { + s, _ := m[key].(string) + return s +} + +// intArg reads an integer argument, tolerating a missing, non-numeric or +// fractional value (0 then means "not supplied", which every caller treats as its +// own default). A decoded-JSON number arrives as float64, hence the type switch. +func intArg(m map[string]any, key string) int { + switch v := m[key].(type) { + case int: + return v + case int32: + return int(v) + case int64: + return int(v) + case float32: + return int(v) + case float64: + return int(v) + default: + return 0 + } +} + +// sourceLog returns l tagged with source "runtime/sessionread", replacing a nil +// logger with a discard one so the reader can always log. +func sourceLog(l *slog.Logger) *slog.Logger { + if l == nil { + l = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + return logger.WithSource(l, "runtime/sessionread") +} diff --git a/internal/runtime/sessionread/tool_test.go b/internal/runtime/sessionread/tool_test.go new file mode 100644 index 0000000..fb41741 --- /dev/null +++ b/internal/runtime/sessionread/tool_test.go @@ -0,0 +1,132 @@ +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:"} { + 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) + } +}