feat(runtime,ai): session_read — 跨会话只读「主动读取」能力(design/22 §8-3/§8.1)
主人 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:
1 parent
05d34853f0
commit
08b3a01752
11 files changed
+1723
No files matched your search
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -600,6 +600,7 @@ func (a *app) wireAuxiliary() error {
|
||||
a.wireSkills()
|
||||
a.wireTerminalTool()
|
||||
a.wireSubagentTool()
|
||||
a.wireSessionReadTool()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user