主人 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 侧成功路径钩子需先定口径。
132 lines
4.0 KiB
Go
132 lines
4.0 KiB
Go
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()))
|
|
}
|