feat(runtime,ai): session_read — 跨会话只读「主动读取」能力(design/22 §8-3/§8.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 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 侧成功路径钩子需先定口径。
This commit is contained in:
AskaEth committed 2026-10-03 21:33:40 +08:00
1 parent 05d34853f0
commit 08b3a01752
11 files changed
+1723

No files matched your search

+97
View File
@@ -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:<uuid> 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 ""
}
+182
View File
@@ -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)
}
}
+1
View File
@@ -600,6 +600,7 @@ func (a *app) wireAuxiliary() error {
a.wireSkills()
a.wireTerminalTool()
a.wireSubagentTool()
a.wireSessionReadTool()
return nil
}
+21
View File
@@ -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:<uuid>") 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
+218
View File
@@ -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)
}
+254
View File
@@ -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)
}
}
@@ -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()))
}
+195
View File
@@ -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:<uuid>)")
// 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:<uuid>").
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:<uuid> 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
}
+312
View File
@@ -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:<uuid> 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=<oldest of the previous
// page> 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)
}
}
+180
View File
@@ -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:<uuid>\") 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:<uuid>\". 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")
}
+132
View File
@@ -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:<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)
}
}