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