Files
AskaEth 08b3a01752
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 侧成功路径钩子需先定口径。
2026-10-03 21:33:40 +08:00

255 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}