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) } }