按 DevDocs/design/22 批次 1 后端(§2/§3/§4.1-4.5/§4.7/§5 验收 1-6)。
- 迁移 0011:chat_sessions.persona_id + chat_messages.persona_id(均 DEFAULT '',无回填 → 升级零风险);同步包内幂等 DDL;**真实库升级验证**(拷贝 data/cyrene.db 到临时目录跑 MigrateUp:既有 1 会话/109 消息 persona 仍为 '',原库未被触碰)
- history:SessionMeta/Message 加 PersonaID + SetSessionPersona;ListSessions/List/scan/Append 带上
- runtime:SessionEnv.PersonaID + loop.personaFor(ctx) 替换固定 l.personaID(memory/request/turn)+ **agent 池**(Options.Agents,按角色取 instructions,未命中回落)+ **提取器不再把 persona 烤死在构造期**(多角色下记忆不再永远记在 cyrene 名下)+ **thinking 旋钮改为按会话角色解析**(否则角色 Y 会用角色 X 的预算/展示)
- gateway:会话 DTO/POST/PATCH 支持 personaId;ws/rest/voice 的 OwnerEnv 硬编改为**按会话解析角色**(actor 元数据语义不变);admin memories 搜索去掉硬编 cyrene
- Docs/api-client v0.42;**两处有意的行为收紧**已写明:POST /sessions body 非法 JSON → 400(原先完全忽略 body;空/null/{} 仍 201);POST /admin/memories/search 省略 personaId 且无默认角色 → 400(原先默认查 cyrene)
- 测试:design/22 §5 验收 1-6(记忆隔离/提示词首条 system/情绪按角色/提取 persona 归属/迁移兼容)+ 进程内 e2e(断言 adapter 收到的 model.Request.Messages[0] 即该角色的 systemPrompt)
- **已知未做(留批次 2 / 待确认)**:① IM 与渠道级未做 → **§7-3「owner 私聊镜像随主会话角色」尚未生效**;② 请求级覆盖未实现(本批只做会话级);③ **§7-5 的「上下文发送角色标记」只完成存储+DTO 半边**——消息 persona 已落库且 DTO 已带,但模型上下文不从 chat_messages 重建,「第 N 条是谁说的」尚未进上下文(需确认是否属批次 2);④「切角色重建摘要」当前是空操作(压缩未接线);⑤ thinking 旋钮仅在流式路径生效(非流式 agent.RunStep 不带,A 批次既有性质)
- 验收:go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译(既有 flake 本轮未触发;同批 session_orchestrator.go 一度 501 行 → 抽出 session_tail.go)
359 lines
14 KiB
Go
359 lines
14 KiB
Go
package history
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
|
)
|
|
|
|
// These tests cover the D4 per-session read cursor (design/16 §4.2, Android
|
|
// collaboration B3): the unread count derived in ListSessions, the forward-only
|
|
// cursor semantics of MarkSessionRead, and the 0010 migration's upgrade backfill.
|
|
|
|
// TestListSessionsUnreadCountFromCursor pins the unread derivation: a message counts as
|
|
// unread only when it is strictly newer than the session's cursor (created_at > read_at),
|
|
// so the cursor is inclusive of the timestamp it names (a client that has rendered a
|
|
// message sent at T marks T read). A fresh session starts at cursor 0 with everything
|
|
// unread, and a cursor at the newest message clears the badge.
|
|
func TestListSessionsUnreadCountFromCursor(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
ctx := context.Background()
|
|
for _, ts := range []int64{1000, 2000, 3000} {
|
|
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: ts}); err != nil {
|
|
t.Fatalf("seed %d: %v", ts, err)
|
|
}
|
|
}
|
|
// Never read: all three messages are unread (a brand-new account/session).
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 3 {
|
|
t.Fatalf("initial unreadCount = %d, want 3", got)
|
|
}
|
|
// Cursor on a message's own timestamp counts that message as read.
|
|
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000))
|
|
if err != nil {
|
|
t.Fatalf("mark read: %v", err)
|
|
}
|
|
if readAt != 2000 {
|
|
t.Fatalf("readAt = %d, want 2000", readAt)
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
|
|
t.Fatalf("unreadCount after cursor 2000 = %d, want 1 (only 3000 newer)", got)
|
|
}
|
|
// A cursor between two messages reads the earlier one only.
|
|
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2500)); err != nil {
|
|
t.Fatalf("mark read 2500: %v", err)
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
|
|
t.Fatalf("unreadCount after cursor 2500 = %d, want 1", got)
|
|
}
|
|
// A cursor at the newest message clears the badge.
|
|
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil {
|
|
t.Fatalf("mark read 3000: %v", err)
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
|
t.Fatalf("unreadCount after cursor 3000 = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestListSessionsUnreadCountSessionIsolation pins the per-session scope: each session
|
|
// carries its own cursor, so reading the main session must not clear (or inflate) a sub
|
|
// session's badge, and vice versa.
|
|
func TestListSessionsUnreadCountSessionIsolation(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
ctx := context.Background()
|
|
sub, err := st.NewSubSession(ctx, "owner")
|
|
if err != nil {
|
|
t.Fatalf("new sub: %v", err)
|
|
}
|
|
for _, m := range []Message{
|
|
{SessionID: MainSessionID, Role: RoleUser, Content: "main-1", CreatedAt: 1000},
|
|
{SessionID: MainSessionID, Role: RoleAssistant, Content: "main-2", CreatedAt: 2000},
|
|
{SessionID: sub, Role: RoleUser, Content: "sub-1", CreatedAt: 1500},
|
|
} {
|
|
if err := st.Append(ctx, m); err != nil {
|
|
t.Fatalf("seed %q: %v", m.Content, err)
|
|
}
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 2 {
|
|
t.Fatalf("main unreadCount = %d, want 2", got)
|
|
}
|
|
if got := sessionOf(t, st, sub).UnreadCount; got != 1 {
|
|
t.Fatalf("sub unreadCount = %d, want 1", got)
|
|
}
|
|
|
|
// Reading the main session must leave the sub untouched.
|
|
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)); err != nil {
|
|
t.Fatalf("mark main read: %v", err)
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
|
t.Fatalf("main unreadCount after read = %d, want 0", got)
|
|
}
|
|
if got := sessionOf(t, st, sub).UnreadCount; got != 1 {
|
|
t.Fatalf("sub unreadCount after main read = %d, want 1 (cursors are per-session)", got)
|
|
}
|
|
|
|
// A message arriving after the read re-opens the main badge.
|
|
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "main-3", CreatedAt: 3000}); err != nil {
|
|
t.Fatalf("append main-3: %v", err)
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
|
|
t.Fatalf("main unreadCount after new message = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestMarkSessionReadForwardOnly covers the racing/malformed-request side of the cursor
|
|
// contract: the cursor never moves backwards, so a stale or repeated request is
|
|
// idempotent and can never resurrect already-read messages.
|
|
func TestMarkSessionReadForwardOnly(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
ctx := context.Background()
|
|
for _, ts := range []int64{1000, 2000, 3000} {
|
|
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: ts}); err != nil {
|
|
t.Fatalf("seed %d: %v", ts, err)
|
|
}
|
|
}
|
|
// Advance to the newest message.
|
|
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000))
|
|
if err != nil || readAt != 3000 {
|
|
t.Fatalf("mark 3000 = (%d, %v), want (3000, nil)", readAt, err)
|
|
}
|
|
// A stale request (an end that was behind, or a retry of an older read) must not
|
|
// move the cursor back and re-open the badge.
|
|
readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(1000))
|
|
if err != nil {
|
|
t.Fatalf("stale mark: %v", err)
|
|
}
|
|
if readAt != 3000 {
|
|
t.Fatalf("stale mark readAt = %d, want the cursor to stay at 3000", readAt)
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
|
t.Fatalf("unreadCount after stale mark = %d, want 0 (never un-read)", got)
|
|
}
|
|
// Repeating the same cursor is a no-op, and a negative cursor is clamped by the
|
|
// same MAX (it cannot move the stored value at all).
|
|
if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil || readAt != 3000 {
|
|
t.Fatalf("repeat mark = (%d, %v), want (3000, nil)", readAt, err)
|
|
}
|
|
if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(-5000)); err != nil || readAt != 3000 {
|
|
t.Fatalf("negative mark = (%d, %v), want (3000, nil)", readAt, err)
|
|
}
|
|
}
|
|
|
|
// TestMarkSessionReadZeroTimeMeansNow covers the "mark everything I have seen" call the
|
|
// HTTP layer makes when the body omits before: the zero time resolves to the wall clock,
|
|
// which reads every message that already exists.
|
|
func TestMarkSessionReadZeroTimeMeansNow(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
ctx := context.Background()
|
|
seedAt := time.Now().UnixMilli()
|
|
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: seedAt}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
|
|
t.Fatalf("unreadCount = %d, want 1", got)
|
|
}
|
|
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("mark zero time: %v", err)
|
|
}
|
|
if readAt < seedAt {
|
|
t.Fatalf("readAt = %d, want >= the seeded message time %d", readAt, seedAt)
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
|
t.Fatalf("unreadCount after mark-now = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
// TestMarkSessionReadUnknownSession pins the error contract: an id with no chat_sessions
|
|
// row (including an isolated group/stranger session) is ErrSessionNotFound, so the HTTP
|
|
// layer can answer 404 instead of silently reporting success for a session that does not
|
|
// exist.
|
|
func TestMarkSessionReadUnknownSession(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
if _, err := st.MarkSessionRead(context.Background(), "web:missing", time.UnixMilli(1000)); !errors.Is(err, ErrSessionNotFound) {
|
|
t.Fatalf("unknown session err = %v, want ErrSessionNotFound", err)
|
|
}
|
|
}
|
|
|
|
// migrationFileSQL reads one migration file from the source tree, so the test applies
|
|
// the same SQL text the embedded migration runs rather than a copy.
|
|
func migrationFileSQL(t *testing.T, name string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name))
|
|
if err != nil {
|
|
t.Fatalf("read migration %s: %v", name, err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// columnPresent reports whether table has a column named column.
|
|
func columnPresent(t *testing.T, conn *sql.DB, table, column string) bool {
|
|
t.Helper()
|
|
var n int
|
|
if err := conn.QueryRowContext(context.Background(),
|
|
"SELECT count(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&n); err != nil {
|
|
t.Fatalf("pragma_table_info(%s): %v", table, err)
|
|
}
|
|
return n == 1
|
|
}
|
|
|
|
// openLegacyDB opens an in-memory DB carrying the pre-0010 schema (chat_sessions with no
|
|
// read_at column) plus two legacy sessions with prior history — the real fixed main
|
|
// session "web" and a sub session — so the 0010 migration can be applied to a database
|
|
// that really predates this feature.
|
|
func openLegacyDB(t *testing.T) *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() })
|
|
ddls := []string{
|
|
`CREATE TABLE chat_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
pinned INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL)`,
|
|
`CREATE TABLE chat_messages (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
mode TEXT,
|
|
mood TEXT,
|
|
image TEXT,
|
|
thinking TEXT,
|
|
platform TEXT NOT NULL DEFAULT 'web',
|
|
device_id TEXT NOT NULL DEFAULT '',
|
|
sender_name TEXT NOT NULL DEFAULT '',
|
|
adapter_id TEXT NOT NULL DEFAULT '',
|
|
created_at INTEGER NOT NULL)`,
|
|
`CREATE INDEX idx_chat_msgs_session_created ON chat_messages (session_id, created_at DESC)`,
|
|
}
|
|
for _, ddl := range ddls {
|
|
if _, err := conn.ExecContext(ctx, ddl); err != nil {
|
|
t.Fatalf("legacy ddl: %v", err)
|
|
}
|
|
}
|
|
legacy := []struct {
|
|
sessionID string
|
|
sessionType string
|
|
messageAt []int64
|
|
}{
|
|
{sessionID: MainSessionID, sessionType: SessionMain, messageAt: []int64{1000, 4000}},
|
|
{sessionID: "web:legacy", sessionType: SessionSub, messageAt: []int64{1000, 2000}},
|
|
}
|
|
for _, l := range legacy {
|
|
if _, err := conn.ExecContext(ctx,
|
|
`INSERT INTO chat_sessions (id, type, title, pinned, created_at) VALUES (?, ?, '', 0, ?)`,
|
|
l.sessionID, l.sessionType, int64(100)); err != nil {
|
|
t.Fatalf("insert legacy session %s: %v", l.sessionID, err)
|
|
}
|
|
for i, ts := range l.messageAt {
|
|
if _, err := conn.ExecContext(ctx,
|
|
`INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
fmt.Sprintf("%s-%d", l.sessionID, i), l.sessionID, RoleUser, "legacy message", ts); err != nil {
|
|
t.Fatalf("insert legacy message: %v", err)
|
|
}
|
|
}
|
|
}
|
|
return conn
|
|
}
|
|
|
|
// TestMigration0010ReadAtBackfill exercises the real 0010 migration against a legacy
|
|
// database: up adds the column and backfills each session's cursor to its newest message,
|
|
// so upgrade day reports 0 unread for existing history — including the fixed main session,
|
|
// whose existing history is the whole point of the upgrade — and the store (which
|
|
// re-issues the canonical DDL idempotently) then works on top of it; a message that arrives
|
|
// after the migration is unread until it is marked read; down drops the column again.
|
|
func TestMigration0010ReadAtBackfill(t *testing.T) {
|
|
ctx := context.Background()
|
|
conn := openLegacyDB(t)
|
|
if columnPresent(t, conn, "chat_sessions", "read_at") {
|
|
t.Fatal("legacy schema should not have read_at before the migration")
|
|
}
|
|
|
|
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0010_chat_session_read_at.up.sql")); err != nil {
|
|
t.Fatalf("apply 0010 up: %v", err)
|
|
}
|
|
// Bring the simulated legacy database up to the current head (0011 adds the
|
|
// session/message persona columns), because the store reads the canonical schema:
|
|
// a database left one migration behind would lack persona_id. Upgrade ordering in
|
|
// production is db.MigrateUp, so this mirrors what a real upgrade produces.
|
|
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0011_session_persona.up.sql")); err != nil {
|
|
t.Fatalf("apply 0011 up: %v", err)
|
|
}
|
|
if !columnPresent(t, conn, "chat_sessions", "read_at") {
|
|
t.Fatal("read_at should exist after the 0010 up migration")
|
|
}
|
|
// The backfill is each session's newest message timestamp, not a blanket 0 (which
|
|
// would mark the whole existing history unread) and not the first message's.
|
|
for _, tc := range []struct {
|
|
sessionID string
|
|
want int64
|
|
}{
|
|
{sessionID: MainSessionID, want: 4000},
|
|
{sessionID: "web:legacy", want: 2000},
|
|
} {
|
|
var backfilled int64
|
|
if err := conn.QueryRowContext(ctx,
|
|
`SELECT read_at FROM chat_sessions WHERE id = ?`, tc.sessionID).Scan(&backfilled); err != nil {
|
|
t.Fatalf("read backfilled cursor %s: %v", tc.sessionID, err)
|
|
}
|
|
if backfilled != tc.want {
|
|
t.Fatalf("backfilled read_at for %s = %d, want its newest legacy message %d", tc.sessionID, backfilled, tc.want)
|
|
}
|
|
}
|
|
|
|
// The store comes up on the migrated schema and reports no unread for either
|
|
// pre-existing session (upgrade day must not light up every row, main session
|
|
// included); the store's INSERT OR IGNORE for the main session must not overwrite
|
|
// the backfilled cursor.
|
|
st, err := NewStore(ctx, conn, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("new store on migrated db: %v", err)
|
|
}
|
|
for _, sessionID := range []string{MainSessionID, "web:legacy"} {
|
|
if got := sessionOf(t, st, sessionID).UnreadCount; got != 0 {
|
|
t.Fatalf("legacy session %s unreadCount = %d, want 0 after backfill", sessionID, got)
|
|
}
|
|
}
|
|
// A message written after the migration is unread, and marking it read clears it.
|
|
if err := st.Append(ctx, Message{SessionID: "web:legacy", Role: RoleAssistant, Content: "new", CreatedAt: 9000}); err != nil {
|
|
t.Fatalf("append after migration: %v", err)
|
|
}
|
|
if got := sessionOf(t, st, "web:legacy").UnreadCount; got != 1 {
|
|
t.Fatalf("unreadCount after post-migration message = %d, want 1", got)
|
|
}
|
|
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
|
t.Fatalf("main unreadCount = %d, want 0 (untouched by the sub session's new message)", got)
|
|
}
|
|
readAt, err := st.MarkSessionRead(ctx, "web:legacy", time.UnixMilli(9000))
|
|
if err != nil || readAt != 9000 {
|
|
t.Fatalf("mark read = (%d, %v), want (9000, nil)", readAt, err)
|
|
}
|
|
if got := sessionOf(t, st, "web:legacy").UnreadCount; got != 0 {
|
|
t.Fatalf("unreadCount after mark = %d, want 0", got)
|
|
}
|
|
|
|
if _, err := conn.ExecContext(ctx, migrationFileSQL(t, "0010_chat_session_read_at.down.sql")); err != nil {
|
|
t.Fatalf("apply 0010 down: %v", err)
|
|
}
|
|
if columnPresent(t, conn, "chat_sessions", "read_at") {
|
|
t.Fatal("read_at should be dropped by the 0010 down migration")
|
|
}
|
|
}
|