package history import ( "context" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) // 本文件锁定聊天消息的 **turn_id** 列(design/15 §2.5.9):它回答「这一行属于哪 // 一路回合」,使客户端在重连 / 回放时仍能精确配对「回复 ↔ 提问」。 // // 三条语义被逐条钉住: // - 落库时已经知道所属路的消息(新起的一路 + 它的助手回复)随 INSERT 写入, // 同一路两条**同值**、两路**不同值**; // - 落库时还不知道所属路的消息(忙时入队 / 被 steer 吃进)留空串,由它实际被 // 处理的那一路**补写**(SetMessagesTurnID),且**先到先得**(已带标识的行不被 // 改写 —— 一行只属于一路); // - 省略标识与加入本列之前逐字相同:空串默认值、读路径照常工作。 func TestAppendFullRoundTripsTurnID(t *testing.T) { t.Parallel() ctx := context.Background() st := newStore(t) user, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "先问你一句", TurnID: "turn-leg1", CreatedAt: 1000}) if err != nil { t.Fatalf("append user: %v", err) } reply, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "答你一句", TurnID: "turn-leg1", CreatedAt: 2000}) if err != nil { t.Fatalf("append assistant: %v", err) } // 第二路(并行)带自己的标识与自己的两条消息。 if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "另一路的问题", TurnID: "turn-leg2", CreatedAt: 3000}); err != nil { t.Fatalf("append parallel user: %v", err) } if user.TurnID != "turn-leg1" || reply.TurnID != "turn-leg1" { t.Fatalf("写入返回值 = %q / %q, want turn-leg1(同一路两条同值)", user.TurnID, reply.TurnID) } // 读路径(共用 messageColumns/scanMessage)必须把标识带出来。 msgs, err := st.List(ctx, MainSessionID, 0, 10, "") if err != nil { t.Fatalf("list: %v", err) } got := make(map[string]string, len(msgs)) for _, m := range msgs { got[m.Content] = m.TurnID } if got["先问你一句"] != "turn-leg1" || got["答你一句"] != "turn-leg1" { t.Fatalf("同一路两条 = %q / %q, want 都是 turn-leg1", got["先问你一句"], got["答你一句"]) } if got["另一路的问题"] != "turn-leg2" { t.Fatalf("另一路 = %q, want turn-leg2(两路绝不同值)", got["另一路的问题"]) } // 其余读面同源(它们共用同一份列清单,这里抽验两条最容易漏改的)。 recent, err := st.Recent(ctx, MainSessionID, 1) if err != nil { t.Fatalf("recent: %v", err) } if len(recent) != 1 || recent[0].TurnID != "turn-leg2" { t.Fatalf("recent = %+v, want 最新那条带 turn-leg2", recent) } hits, err := st.Search(ctx, MainSessionID, "先问你一句", 0, 10) if err != nil { t.Fatalf("search: %v", err) } if len(hits) != 1 || hits[0].TurnID != "turn-leg1" { t.Fatalf("search = %+v, want 命中那条带 turn-leg1", hits) } found, err := st.FindByClientMsgID(ctx, MainSessionID, "") if err == nil { t.Fatalf("FindByClientMsgID(空键) = %+v, want ErrMessageNotFound", found) } } func TestAppendWithoutTurnIDLeavesItEmpty(t *testing.T) { t.Parallel() ctx := context.Background() st := newStore(t) // 省略标识(老路径 / IM 镜像 / 排队入队)与加入本列之前逐字相同:空串。 if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "没有回合标识", CreatedAt: 1000}); err != nil { t.Fatalf("append: %v", err) } msgs, err := st.List(ctx, MainSessionID, 0, 10, "") if err != nil { t.Fatalf("list: %v", err) } if len(msgs) != 1 || msgs[0].TurnID != "" { t.Fatalf("msgs = %+v, want 空 turn_id(省略 ⇒ 不属于任何已知回合)", msgs) } } func TestSetMessagesTurnIDAssignsUnassignedOnly(t *testing.T) { t.Parallel() ctx := context.Background() st := newStore(t) // 两行在落库时都还没有所属的路(忙时入队 / 被 steer 吃进的那种行)。 queued, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "排队的一句", CreatedAt: 1000}) if err != nil { t.Fatalf("append queued: %v", err) } steered, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "被转向吃进的一句", CreatedAt: 1500}) if err != nil { t.Fatalf("append steered: %v", err) } // 第二段:由**实际处理它们的那一路**补写。 if err := st.SetMessagesTurnID(ctx, []string{queued.ID, steered.ID}, "turn-actual"); err != nil { t.Fatalf("set turn id: %v", err) } msgs, err := st.List(ctx, MainSessionID, 0, 10, "") if err != nil { t.Fatalf("list: %v", err) } for _, m := range msgs { if m.TurnID != "turn-actual" { t.Fatalf("消息 %q 的 turn_id = %q, want turn-actual(按实际处理它的那一路写)", m.Content, m.TurnID) } } // 先到先得:已经带上标识的行绝不被后来者改写(一行只属于一路)。 if err := st.SetMessagesTurnID(ctx, []string{queued.ID}, "turn-later"); err != nil { t.Fatalf("set turn id again: %v", err) } msgs, err = st.List(ctx, MainSessionID, 0, 10, "") if err != nil { t.Fatalf("list after re-set: %v", err) } for _, m := range msgs { if m.TurnID != "turn-actual" { t.Fatalf("消息 %q 的 turn_id 被改写成 %q, want 仍是 turn-actual", m.Content, m.TurnID) } } // 未知 id 被忽略而不是报错:第二段的常态是「刚落的行」,找不到只可能是它已被删除。 if err := st.SetMessagesTurnID(ctx, []string{"不存在的 id"}, "turn-actual"); err != nil { t.Fatalf("unknown id: %v", err) } } func TestSetMessagesTurnIDNoopInputs(t *testing.T) { t.Parallel() ctx := context.Background() st := newStore(t) row, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "保持空", CreatedAt: 1000}) if err != nil { t.Fatalf("append: %v", err) } // 空 id 列表、空标识、只含空串的列表都是空操作(调用方不必先判断)。 cases := []struct { name string ids []string turnID string }{ {name: "no ids", ids: nil, turnID: "turn-x"}, {name: "no turn id", ids: []string{row.ID}, turnID: ""}, {name: "blank ids", ids: []string{"", ""}, turnID: "turn-x"}, } for _, tc := range cases { if err := st.SetMessagesTurnID(ctx, tc.ids, tc.turnID); err != nil { t.Fatalf("%s: %v", tc.name, err) } } msgs, err := st.List(ctx, MainSessionID, 0, 10, "") if err != nil { t.Fatalf("list: %v", err) } if len(msgs) != 1 || msgs[0].TurnID != "" { t.Fatalf("msgs = %+v, want 空 turn_id(空输入不写任何东西)", msgs) } // 重复 id 只算一次,且整批落到同一个标识上。 if err := st.SetMessagesTurnID(ctx, []string{row.ID, row.ID, "", row.ID}, "turn-dup"); err != nil { t.Fatalf("duplicate ids: %v", err) } msgs, err = st.List(ctx, MainSessionID, 0, 10, "") if err != nil { t.Fatalf("list after dup: %v", err) } if msgs[0].TurnID != "turn-dup" { t.Fatalf("turn_id = %q, want turn-dup", msgs[0].TurnID) } } // TestNewStoreRollsForwardTurnIDColumn 验证早于 0018 建出的 chat_messages 表会被 // NewStore 幂等前滚出 turn_id 列(与 client_msg_id / document 同一手法):全新库由 // 建表 DDL 一次建全,既有库靠补列,两条路都必须能写能读带标识的消息。 func TestNewStoreRollsForwardTurnIDColumn(t *testing.T) { t.Parallel() 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() }) // 0018 之前的形状:有 document(0015)但没有 turn_id。 const legacyTable = ` 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, document TEXT NOT NULL DEFAULT '', 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 '', persona_id TEXT NOT NULL DEFAULT '', client_msg_id TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL )` if _, err := conn.ExecContext(ctx, legacyTable); err != nil { t.Fatalf("create legacy table: %v", err) } st, err := NewStore(ctx, conn, testLogger()) if err != nil { t.Fatalf("new store: %v", err) } if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "前滚之后", TurnID: "turn-rolled", CreatedAt: 2000}); err != nil { t.Fatalf("append after roll forward: %v", err) } msgs, err := st.List(ctx, MainSessionID, 0, 10, "") if err != nil { t.Fatalf("list: %v", err) } if len(msgs) != 1 || msgs[0].TurnID != "turn-rolled" { t.Fatalf("msgs = %+v, want turn-rolled 往返", msgs) } }