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,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()))
|
||||
}
|
||||
Reference in new issue
Block a user