fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
  改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
  承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
  兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
  未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
  原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
  ② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
  重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
  `clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
  已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
  四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
  迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
  (0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
This commit is contained in:
AskaEth committed 2026-10-04 14:37:37 +08:00
1 parent da3a386daf
commit 7512d593c2
18 files changed
+1573 -216

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
package history
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// 本文件归属聊天消息的**客户端幂等键**(Android 协同 §G4)。
//
// 它是与会话编排器(internal/services/gateway/session_orchestrator.go)分摊的
// 一件事的两半:编排器决定「这一轮还要不要跑」,存储决定「这一行还要不要写」。
// 存储这半边的核心是 (session_id, client_msg_id) 上的部分唯一索引(见 0013
// 迁移与本文件的 createClientMsgIDIndex)—— 判重由数据库原子裁决,因此并发的
// 两次重发只有一个赢家,不存在「先查再插」的竞态窗口。
//
// client_msg_id 为空串表示「这条消息没有幂等键」(老客户端、IM 镜像、历史行),
// 部分索引刻意把它排除在外:否则同一会话的第二条空键消息就会撞上唯一约束。
// NewMessageID 返回一个新的消息 id(128 位密码学随机、十六进制编码),与
// insert 在调用方省略 ID 时分配的 id 同源。它被会话编排器用来**预分配**一条
// 用户消息的 id:这样同一条 clientMsgId 的并发重发即使赶在第一条真正落库之前
// 到达,也能立刻拿到一个确定且终将存在的 messageId(而不是空值)。
func NewMessageID() (string, error) {
return newID()
}
// FindByClientMsgID 按 (sessionID, clientMsgID) 读回既有消息,供幂等判重。
// 没有该行时返回包装了 ErrMessageNotFound 的错误;clientMsgID 为空时同理
// (空键不是一条可判重的消息)。查询走部分唯一索引,至多命中一行。
func (s *Store) FindByClientMsgID(ctx context.Context, sessionID, clientMsgID string) (Message, error) {
if clientMsgID == "" {
return Message{}, fmt.Errorf("history: find client msg in %s: %w", sessionID, ErrMessageNotFound)
}
row := s.db.QueryRowContext(ctx, `SELECT `+messageColumns+`
FROM chat_messages WHERE session_id = ? AND client_msg_id = ?`, sessionID, clientMsgID)
m, err := scanMessage(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Message{}, fmt.Errorf("history: find client msg in %s: %w", sessionID, ErrMessageNotFound)
}
return Message{}, fmt.Errorf("history: find client msg in %s: %w", sessionID, err)
}
return m, nil
}
// createClientMsgIDIndex 建 (session_id, client_msg_id) 的**部分**唯一索引
// (只覆盖非空键)。它同时是幂等写入的冲突裁决者:insert 带非空
// ClientMsgID 时用 ON CONFLICT DO NOTHING,由该索引决定谁写下这一行。
const createClientMsgIDIndex = `
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_client_msg_id
ON chat_messages (session_id, client_msg_id) WHERE client_msg_id != ''`
// ensureClientMsgIDColumn 在 chat_messages 早于本列(0013 迁移)建表时把它
// 前滚:缺列则补加,已有则忽略重复列错误。它是幂等的,因此 NewStore 可在全新
// schema 或既有 schema 上调用(与 memory 包的 ensureProjectIDColumn 同一手法)。
func ensureClientMsgIDColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE chat_messages
ADD COLUMN client_msg_id TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
// 列已存在时(由 createMessagesTable 建出,或上一次运行补过)SQLite 报
// "duplicate column name: client_msg_id"。
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("history: roll forward client_msg_id column: %w", err)
}
@@ -0,0 +1,111 @@
package history
import (
"context"
"errors"
"testing"
)
// 本文件锁定 Android 协同 §G4 的存储半边:客户端幂等键(client_msg_id)由
// (session_id, client_msg_id) 的**部分唯一索引**原子裁决,因此判重不是「先查
// 再插」的竞态写法,而是「写入被数据库拒绝,然后读回既有那一行」。
// 部分(WHERE client_msg_id != '')是必须的:否则同一会话的所有空键行会互相
// 冲突,等于禁止一个会话出现第二条消息。
func TestAppendFullDedupesClientMsgID(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
first, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "同一段", ClientMsgID: "cm-1"})
if err != nil {
t.Fatalf("第一次写入:%v", err)
}
// 第二次:换一个 id(像客户端重发时服务端会另铸一个),同一个 clientMsgId。
second, err := st.AppendFull(ctx, Message{ID: "重发时的新 id", SessionID: MainSessionID, Role: RoleUser, Content: "同一段", ClientMsgID: "cm-1"})
if err != nil {
t.Fatalf("第二次写入:%v", err)
}
if second.ID != first.ID {
t.Fatalf("第二次返回的 id = %q,want 既有那条 %q(唯一索引裁决,不新写一行)", second.ID, first.ID)
}
if second.ClientMsgID != "cm-1" {
t.Fatalf("第二次返回的消息 = %+v,want 回填的 clientMsgId", second)
}
msgs, err := st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("行数 = %d, want 1(同一个 clientMsgId 只落一条)", len(msgs))
}
if msgs[0].ClientMsgID != "cm-1" {
t.Fatalf("读回的消息 = %+v,want clientMsgId=cm-1(读接口可见)", msgs[0])
}
// 省略幂等键(空串)不参与约束:同一会话的两条空键消息都必须写得进去。
for i := 0; i < 2; i++ {
if _, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "无键"}); err != nil {
t.Fatalf("第 %d 条空键写入:%v", i+1, err)
}
}
msgs, err = st.List(ctx, MainSessionID, 0, 50, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("行数 = %d, want 3(空 clientMsgId 绝不受唯一索引约束)", len(msgs))
}
}
func TestAppendDedupesClientMsgIDPerSession(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
// 幂等键只在**会话内**唯一:两个会话用同一个键互不影响
// (client_msg_id 由客户端生成,不同会话之间没有可比性)。
main, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "主会话", ClientMsgID: "cm-x"})
if err != nil {
t.Fatalf("主会话写入:%v", err)
}
sub := MainSessionID + ":sub-1"
other, err := st.AppendFull(ctx, Message{SessionID: sub, Role: RoleUser, Content: "子会话", ClientMsgID: "cm-x"})
if err != nil {
t.Fatalf("子会话写入:%v", err)
}
if other.ID == main.ID {
t.Fatalf("子会话返回了主会话的行 %q,want 各自一条", main.ID)
}
}
func TestFindByClientMsgID(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newStore(t)
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "定位我", ClientMsgID: "cm-find"})
if err != nil {
t.Fatalf("写入:%v", err)
}
got, err := st.FindByClientMsgID(ctx, MainSessionID, "cm-find")
if err != nil {
t.Fatalf("按幂等键读取:%v", err)
}
if got.ID != full.ID || got.ClientMsgID != "cm-find" {
t.Fatalf("读回 = %+v,want id=%q clientMsgId=cm-find", got, full.ID)
}
// 没见过的键与空键都必须是明确的 ErrMessageNotFound(编排器据此判定「不是
// 重复」,而不是把查询错误当成重复)。
for _, key := range []string{"cm-missing", ""} {
if _, err := st.FindByClientMsgID(ctx, MainSessionID, key); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("键 %q 的错误 = %v,want wrap of ErrMessageNotFound", key, err)
}
}
// 会话也必须匹配:同一个键在别的会话里不存在。
if _, err := st.FindByClientMsgID(ctx, MainSessionID+":other", "cm-find"); !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("跨会话查询的错误 = %v,want wrap of ErrMessageNotFound", err)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// 消息保持插入顺序。返回切片非 nil;limit 钳制到 [1, maxPageLimit]。
func (s *Store) ListAfter(ctx context.Context, sessionID string, after int64, limit int, platform string) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
query := `SELECT ` + messageColumns + `
FROM chat_messages WHERE session_id = ? AND created_at > ?`
args := []any{sessionID, after}
if platform != "" {
+160
View File
@@ -0,0 +1,160 @@
package history
import (
"context"
"fmt"
)
// 本文件归属 chat_messages 的**日志与窗口读取**:适配器日志页的三个粒度
// (单适配器 / 单平台 / 全适配器)与 WS 回放/上下文尾部用的 Recent。它们与
// store.go 的 List/Search 分开按职责分文件,并共用 store.go 的 messageColumns
// 列清单与 scanMessage,因此「一列都不许漏」的口径仍然只有一处。
// ListPlatformLog 按最新优先返回 platform 跨所有非主(隔离)会话的至多 limit
// 条消息。主会话消息被排除,因此只有该平台自己的消息流(群聊 + 陌生人私聊)
// 浮现到适配器日志页(design/16 §4.6 §4)。before 是毫秒游标;非零时只返回
// 严格更旧的消息。返回切片非 nil。
func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT ` + messageColumns + `
FROM chat_messages WHERE platform = ? AND session_id != ?`
args := []any{platform, MainSessionID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: platform log %s: %w", platform, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan platform log %s: %w", platform, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate platform log %s: %w", platform, err)
}
return msgs, nil
}
// ListAdapterLog 按最新优先返回单个 IM 适配器(平台 + 账号注册名,例如
// "kook:3bef...")的至多 limit 条消息,供按适配器的日志页使用。它不排除主 Web
// 会话:主人的 IM 私聊住在主会话里但仍携带 adapter_id,因此按适配器的日志包含
// 主人镜像。adapterID 为空时返回空(非 nil)切片。before 是毫秒游标;非零时只
// 返回严格更旧的消息。返回切片非 nil。
func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
if adapterID == "" {
return make([]Message, 0), nil
}
query := `SELECT ` + messageColumns + `
FROM chat_messages WHERE adapter_id = ?`
args := []any{adapterID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: adapter log %s: %w", adapterID, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan adapter log %s: %w", adapterID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate adapter log %s: %w", adapterID, err)
}
return msgs, nil
}
// ListAllLog 按最新优先返回跨所有 IM 适配器来源(adapter_id 非空)的至多 limit
// 条消息,供适配器总览页使用。before 是毫秒游标;非零时只返回严格更旧的消息。
// 返回切片非 nil。
func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT ` + messageColumns + `
FROM chat_messages WHERE adapter_id != ''`
var args []any
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: all adapter log: %w", err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan all adapter log: %w", err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate all adapter log: %w", err)
}
return msgs, nil
}
// Recent 按最旧优先返回 sessionID 最近的 n 条消息(最靠尾部的那段窗口),供
// WS 回放 / 上下文尾部使用。返回切片非 nil。
//
// 并列打破(裁决 13.4.5 的上下文「最新性/一致性」):同一毫秒写入的多条消息
// 在 created_at 上并列,而 Windows 上 time.Now() 的步长本就约 0.44ms —— 用户
// 连发多条(design/15 §2.1 的忙碌续接场景)时并列是常态。`rowid DESC` 让 LIMIT
// 取到的是**最后插入**的那几条(否则窗口会留下更旧的、丢掉刚发出的),而反转后
// 同毫秒内即回到插入顺序,使续接窗口的时序与真实对话一致。该口径与 ListAfter
// 的「rowid 打破 created_at 的并列」相同。
func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) {
if n < 1 {
n = defaultPageLimit
}
rows, err := s.db.QueryContext(ctx, `SELECT `+messageColumns+`
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?`, sessionID, n)
if err != nil {
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
}
defer func() { _ = rows.Close() }()
// Recent 让最新的排在最后,使窗口处于时间顺序;先按查询顺序构建再原地反转。
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan recent %s: %w", sessionID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate recent %s: %w", sessionID, err)
}
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
+2 -2
View File
@@ -25,7 +25,7 @@ func (s *Store) Search(ctx context.Context, sessionID, query string, before int6
if query == "" {
return make([]Message, 0), nil
}
sqlText := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
sqlText := `SELECT ` + messageColumns + `
FROM chat_messages WHERE session_id = ? AND content LIKE ? ESCAPE '\'`
args := []any{sessionID, "%" + escapeLike(query) + "%"}
if before > 0 {
@@ -98,7 +98,7 @@ func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) {
// messageByID 按 id 读取一条消息,行不存在时返回 ErrMessageNotFound。
func (s *Store) messageByID(ctx context.Context, id string) (Message, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
row := s.db.QueryRowContext(ctx, `SELECT `+messageColumns+`
FROM chat_messages WHERE id = ?`, id)
m, err := scanMessage(row)
if err != nil {
+58 -160
View File
@@ -106,7 +106,14 @@ type Message struct {
// 一致。它只是展示/归属元数据:身份与权限由渠道 actor 元数据决定
// (项目红线)。
PersonaID string
CreatedAt int64
// ClientMsgID 是客户端为这条消息生成的幂等键(Android 协同 §G4)—— 通常是
// 一条乐观气泡的本地 id,也正因此它必须原样回填并可见:客户端用它把本地
// 气泡与服务端真相对齐,而不是靠「文本相等」这类脆弱匹配。同一会话内同一
// 非空键只允许一条消息(部分唯一索引裁决,见 client_msg_id.go)。空串意味着
// 「这条消息没有幂等键」(老客户端、IM 镜像、历史行),行为与加入该列之前
// 逐字相同。
ClientMsgID string
CreatedAt int64
}
// SessionMeta 是一个持久化会话面向线上的元数据。
@@ -221,18 +228,31 @@ type rowScanner interface {
}
// NewStore 在 db 上构建一个 Store,确保 chat_sessions/chat_messages schema 存在
// 且固定主会话行在位。该 DDL 与 0003 迁移一致;它是幂等的,因此存储自足、可从
// 任意 *sql.DB 使用,与 memory 和 session 包一致。db 必须可用;nil 返回
// ErrNilDB。log 可以为 nil。
// 且固定主会话行在位。该 DDL 与 0003 迁移一致(列清单随后续迁移同步增长,见
// client_msg_id.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与
// memory 和 session 包一致。db 必须可用;nil 返回 ErrNilDB。log 可以为 nil。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
}
log = logger.WithSource(discardLogger(log), "history")
// 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后
// ensureClientMsgIDColumn 把早于该列建出的既有表前滚(幂等),最后才建索引
// —— 部分唯一索引引用 client_msg_id,列还不存在时建它必然失败。
for _, ddl := range []string{
createSessionsTable,
createMessagesTable,
} {
if _, err := db.ExecContext(ctx, ddl); err != nil {
return nil, fmt.Errorf("history: apply schema: %w", err)
}
}
if err := ensureClientMsgIDColumn(ctx, db); err != nil {
return nil, err
}
for _, ddl := range []string{
createMessagesIndex,
createClientMsgIDIndex,
} {
if _, err := db.ExecContext(ctx, ddl); err != nil {
return nil, fmt.Errorf("history: apply schema: %w", err)
@@ -262,6 +282,12 @@ func (s *Store) AppendFull(ctx context.Context, msg Message) (Message, error) {
// insert 是 Append/AppendFull 共用的主体。它在调用方把 id/CreatedAt 留空/留零时
// 分配新的 id 与现在的毫秒时间戳,然后插入该行并返回得到的消息。
//
// msg.ClientMsgID 非空时(Android 协同 §G4,客户端幂等键)写入走
// ON CONFLICT DO NOTHING,由 (session_id, client_msg_id) 的部分唯一索引**原子**
// 裁决谁写下了这一行;命中冲突时不写入,改读回既有那一行返回(因此调用方拿到
// 的是权威消息,而不是一条并不存在的「刚插入的行」)。判重不在这里「先查再插」
// —— 那是两个并发重发都能通过检查、然后双双插入的竞态。
func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
if msg.ID == "" {
id, err := newID()
@@ -279,14 +305,27 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
if msg.Role == RoleUser {
s.ensureTitle(ctx, msg.SessionID, msg.Content)
}
_, err := s.db.ExecContext(ctx, `INSERT INTO chat_messages
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
query := `INSERT INTO chat_messages
(id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if msg.ClientMsgID != "" {
query += ` ON CONFLICT DO NOTHING`
}
res, err := s.db.ExecContext(ctx, query,
msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood,
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.PersonaID, msg.CreatedAt)
msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.PersonaID, msg.ClientMsgID, msg.CreatedAt)
if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
if msg.ClientMsgID != "" {
n, err := res.RowsAffected()
if err != nil {
return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err)
}
if n == 0 {
return s.FindByClientMsgID(ctx, msg.SessionID, msg.ClientMsgID)
}
}
return msg, nil
}
@@ -297,7 +336,7 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) {
// 通道)。返回切片非 nil。limit 钳制到 [1, maxPageLimit]。
func (s *Store) List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
query := `SELECT ` + messageColumns + `
FROM chat_messages WHERE session_id = ?`
args := []any{sessionID}
if before > 0 {
@@ -331,155 +370,6 @@ func (s *Store) List(ctx context.Context, sessionID string, before int64, limit
return msgs, nil
}
// ListPlatformLog 按最新优先返回 platform 跨所有非主(隔离)会话的至多 limit
// 条消息。主会话消息被排除,因此只有该平台自己的消息流(群聊 + 陌生人私聊)
// 浮现到适配器日志页(design/16 §4.6 §4)。before 是毫秒游标;非零时只返回
// 严格更旧的消息。返回切片非 nil。
func (s *Store) ListPlatformLog(ctx context.Context, platform string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
FROM chat_messages WHERE platform = ? AND session_id != ?`
args := []any{platform, MainSessionID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: platform log %s: %w", platform, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan platform log %s: %w", platform, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate platform log %s: %w", platform, err)
}
return msgs, nil
}
// ListAdapterLog 按最新优先返回单个 IM 适配器(平台 + 账号注册名,例如
// "kook:3bef...")的至多 limit 条消息,供按适配器的日志页使用。它不排除主 Web
// 会话:主人的 IM 私聊住在主会话里但仍携带 adapter_id,因此按适配器的日志包含
// 主人镜像。adapterID 为空时返回空(非 nil)切片。before 是毫秒游标;非零时只
// 返回严格更旧的消息。返回切片非 nil。
func (s *Store) ListAdapterLog(ctx context.Context, adapterID string, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
if adapterID == "" {
return make([]Message, 0), nil
}
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
FROM chat_messages WHERE adapter_id = ?`
args := []any{adapterID}
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: adapter log %s: %w", adapterID, err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan adapter log %s: %w", adapterID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate adapter log %s: %w", adapterID, err)
}
return msgs, nil
}
// ListAllLog 按最新优先返回跨所有 IM 适配器来源(adapter_id 非空)的至多 limit
// 条消息,供适配器总览页使用。before 是毫秒游标;非零时只返回严格更旧的消息。
// 返回切片非 nil。
func (s *Store) ListAllLog(ctx context.Context, before int64, limit int) ([]Message, error) {
limit = clampLimit(limit)
query := `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
FROM chat_messages WHERE adapter_id != ''`
var args []any
if before > 0 {
query += ` AND created_at < ?`
args = append(args, before)
}
query += ` ORDER BY created_at DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("history: all adapter log: %w", err)
}
defer func() { _ = rows.Close() }()
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan all adapter log: %w", err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate all adapter log: %w", err)
}
return msgs, nil
}
// Recent 按最旧优先返回 sessionID 最近的 n 条消息(最靠尾部的那段窗口),供
// WS 回放 / 上下文尾部使用。返回切片非 nil。
//
// 并列打破(裁决 13.4.5 的上下文「最新性/一致性」):同一毫秒写入的多条消息
// 在 created_at 上并列,而 Windows 上 time.Now() 的步长本就约 0.44ms —— 用户
// 连发多条(design/15 §2.1 的忙碌续接场景)时并列是常态。`rowid DESC` 让 LIMIT
// 取到的是**最后插入**的那几条(否则窗口会留下更旧的、丢掉刚发出的),而反转后
// 同毫秒内即回到插入顺序,使续接窗口的时序与真实对话一致。该口径与 ListAfter
// 的「rowid 打破 created_at 的并列」相同。
func (s *Store) Recent(ctx context.Context, sessionID string, n int) ([]Message, error) {
if n < 1 {
n = defaultPageLimit
}
rows, err := s.db.QueryContext(ctx, `SELECT id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, created_at
FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?`, sessionID, n)
if err != nil {
return nil, fmt.Errorf("history: recent %s: %w", sessionID, err)
}
defer func() { _ = rows.Close() }()
// Recent 让最新的排在最后,使窗口处于时间顺序;先按查询顺序构建再原地反转。
msgs := make([]Message, 0)
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, fmt.Errorf("history: scan recent %s: %w", sessionID, err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("history: iterate recent %s: %w", sessionID, err)
}
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
// DeleteSession 在一个事务中删除一个子会话及其全部消息。固定主会话不可变,
// 返回 ErrImmutableMain(design/16 §6)。
func (s *Store) DeleteSession(ctx context.Context, sessionID string) error {
@@ -523,11 +413,18 @@ func (s *Store) ensureMainSession(ctx context.Context) error {
return nil
}
// scanMessage 读取一行 chat_messages。消息各列按位置扫描。
// messageColumns 是 scanMessage 期望的 chat_messages 列清单,也是每条读取
// 语句(List/ListAfter/Recent/Search/messageByID/FindByClientMsgID)共用的
// **唯一**一份列清单(位置顺序必须与 scanMessage 的 Scan 完全一致)。
// 它刻意只写一次:此前七个查询各抄一份,加一列就得改七处,漏一处就只会在
// 运行时以「扫描到的列数与列不清单不符」炸掉;共用常量让漏改成为不可能。
const messageColumns = `id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at`
// scanMessage 读取一行 chat_messages。消息各列按位置扫描,顺序见 messageColumns。
func scanMessage(sc rowScanner) (Message, error) {
var m Message
var mode, mood, platform, deviceID, senderName, adapterID string
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.CreatedAt); err != nil {
if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.CreatedAt); err != nil {
return Message{}, err
}
m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID
@@ -592,6 +489,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
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
)`
@@ -0,0 +1,228 @@
package loop
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// TestRun_WorkingMemory_WriteOnSettle_回合落定写入工作记忆 是审计 P0-4 的
// 主断言:跑完一个回合后,该会话的工作记忆窗口非空、摘要非空,且
// assembleContext 的读点(loop/memory.go)真的把它作为前导块并入 ——
// 接线之前生产代码从不调用 Add,窗口恒空、块永不出现。
func TestRun_WorkingMemory_WriteOnSettle_回合落定写入工作记忆(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
wm := memory.NewWorkingMemory(0)
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好的"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{
Agent: a, Memory: store, WorkingMemory: wm,
PersonaID: "persona-a", MaxRounds: 8,
})
ctx := WithSessionEnv(context.Background(), OwnerEnv())
msgs := []model.Message{model.TextMessage(model.RoleUser, "帮我查一下资料")}
if _, err := l.Run(ctx, "web:wm", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
win := wm.SessionWindow("web:wm")
if len(win) == 0 {
t.Fatal("工作记忆会话窗口仍为空:回合落定没有写入(P0-4 未修)")
}
if win[0].Content[0].Text != "帮我查一下资料" {
t.Fatalf("窗口首条 = %q, want 本轮用户消息", win[0].Content[0].Text)
}
if got := wm.SummarizeSession("web:wm"); got == "" {
t.Fatal("SummarizeSession 仍为 \"\":读点拿不到内容")
}
// 读点:下一次组装把工作记忆并入前导块(此前该块永不出现)。
// 组装在生产路径上总带着该 Run 的会话 id(Run 顶部 withLoopSession),
// 这里同样把会话挂上,免得读到别的会话的窗口。
view := l.assembleContext(withLoopSession(ctx, "web:wm"), msgs, 1)
if len(view) != 2 {
t.Fatalf("assemble = %d 条, want 2(工作记忆前导块 + 对话)", len(view))
}
if head := messageText(view[0]); !strings.Contains(head, "工作记忆") {
t.Fatalf("assemble 首条不是工作记忆块: %q", head)
}
// 会话恢复:同一会话再来一个回合(携带完整历史)时窗口被回填,
// 且不与其他会话/默认窗口混同。
wm.RecordSession("web:other", []model.Message{model.TextMessage(model.RoleUser, "另一个会话")})
if got := wm.SummarizeSession("web:wm"); strings.Contains(got, "另一个会话") {
t.Fatalf("会话隔离被破坏: %q", got)
}
if got := wm.Summarize(); got != "" {
t.Fatalf("默认窗口被回合写入污染: %q", got)
}
}
// TestRun_IntentWriteback_OwnerLearns_闭环生效 是审计 P0-17 的主断言:
// owner 会话跑完一个任务回合后,L4 判定被**真实写回记忆**,并且这条
// 记忆能被 L4 读回 —— 一个自身没有强信号的后续表达因此被唤醒。
// 对照(变异反证):不喂召回的同一判定保持静息。
func TestRun_IntentWriteback_OwnerLearns_闭环生效(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
ret := memory.NewRetriever(store)
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "好的"}}}}
a := newTestAgent(t, ad, nil)
l := New(Options{
Agent: a, Memory: store, Retriever: ret, Capacity: dec,
PersonaID: "persona-a", MaxRounds: 8,
})
ctx := WithSessionEnv(context.Background(), OwnerEnv())
if _, err := l.Run(ctx, "web:l4", []model.Message{model.TextMessage(model.RoleUser, "帮我搞一下那个部署")}); err != nil {
t.Fatalf("Run: %v", err)
}
items, err := store.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(items) != 1 {
t.Fatalf("回合落定产出 %d 条记忆, want 1(L4 判定回写,P0-17 未修)", len(items))
}
record := items[0]
if !strings.HasPrefix(record.Content, "intent:") {
t.Fatalf("回写内容 = %q, want capacity.RecordIntent 的规范格式", record.Content)
}
if record.Scope != memory.ScopeUser {
t.Fatalf("回写作用域 = %q, want user(用户习惯域,L4 的读域)", record.Scope)
}
if record.PersonaID != "persona-a" {
t.Fatalf("回写角色 = %q, want persona-a", record.PersonaID)
}
if _, ok := memory.ParseSourceConversation(record.SourceConversation); !ok {
t.Fatalf("回写溯源 = %q, want 结构化 session/turn 溯源", record.SourceConversation)
}
// 闭环:这条记忆被真实召回后,L4 唤醒一个自身无强信号的表达。
const probe = "那个部署怎么样了"
recall := l.capacityRecall(ctx, []model.Message{model.TextMessage(model.RoleUser, probe)})
if len(recall) == 0 {
t.Fatal("写回的记忆没有被检索召回,闭环不成立")
}
without := dec.Decide(ctx, capacity.Input{Messages: []string{probe}})
if without.State != capacity.StateQuiet {
t.Fatalf("前提不成立:探针文本自身已有强信号(state=%q)", without.State)
}
with := dec.Decide(ctx, capacity.Input{Messages: []string{probe}, Recall: recall})
if with.State == capacity.StateQuiet {
t.Fatalf("L4 未从写回的记忆中学到习惯:state=%q(want 唤醒)", with.State)
}
}
// TestRun_MemoryHooks_RestrictedSessionsSkip_受限会话零写入 是 P0-4/P0-17
// 的红线反证:群聊与陌生人会话(MemWritable=false)既不写共享记忆、
// 也不落任何工作记忆,且别的主人的会话窗口绝不泄露到这些上下文里。
func TestRun_MemoryHooks_RestrictedSessionsSkip_受限会话零写入(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
wm := memory.NewWorkingMemory(0)
dec := capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6})
ad := &mockAdapter{}
a := newTestAgent(t, ad, nil)
l := New(Options{
Agent: a, Memory: store, Retriever: memory.NewRetriever(store), Capacity: dec,
WorkingMemory: wm, PersonaID: "persona-a", MaxRounds: 8,
})
// owner 会话先写出一条意图记录与一个工作记忆窗口。
ownerCtx := WithSessionEnv(context.Background(), OwnerEnv())
if _, err := l.Run(ownerCtx, "web:owner", []model.Message{model.TextMessage(model.RoleUser, "帮我搞一下那个部署")}); err != nil {
t.Fatalf("owner Run: %v", err)
}
ownerItems, err := store.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(ownerItems) != 1 {
t.Fatalf("owner 回合写入 %d 条, want 1", len(ownerItems))
}
restricted := []struct {
name string
env SessionEnv
}{
{"group", SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}},
{"stranger", SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}},
}
// 主人私有的意图记录(含原话)落在共享的用户习惯域以便 L4 闭环,
// 但绝不能被群聊/陌生人读走:受限块里只允许出现非敏感共享记忆。
seedIntentAndHabit(t, store)
before, err := store.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
for _, tc := range restricted {
ctx := WithSessionEnv(context.Background(), tc.env)
if _, err := l.Run(ctx, "sess-"+tc.name, []model.Message{model.TextMessage(model.RoleUser, "帮我搞一下那个部署")}); err != nil {
t.Fatalf("%s Run: %v", tc.name, err)
}
if got := len(wm.SessionWindow("sess-" + tc.name)); got != 0 {
t.Fatalf("%s 会话写了工作记忆: %d 条(只读隔离被破坏)", tc.name, got)
}
// 主人会话的窗口绝不进入受限上下文的前导块。
view := l.assembleContext(ctx, []model.Message{model.TextMessage(model.RoleUser, "go")}, 1)
// 正向对照:非敏感共享记忆(用户习惯)确实注入,证明这个块不是
// 空的 —— 因此下面的「不出现」是真过滤,而不是什么都没注入。
block := ""
for _, m := range view {
block += messageText(m) + "\n"
}
if !strings.Contains(block, "喜欢手冲咖啡") {
t.Fatalf("%s 上下文缺少非敏感共享记忆(测试前提不成立):\n%s", tc.name, block)
}
for _, m := range view {
text := messageText(m)
if strings.Contains(text, "工作记忆") {
t.Fatalf("%s 上下文泄露了工作记忆块: %q", tc.name, text)
}
if strings.Contains(text, "intent:") {
t.Fatalf("%s 上下文泄露了主人的 L4 意图记录(原话): %q", tc.name, text)
}
if strings.Contains(text, "帮我搞一下那个部署") {
t.Fatalf("%s 上下文泄露了主人原话: %q", tc.name, text)
}
}
}
after, err := store.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(after) != len(before) {
t.Fatalf("受限会话写入了记忆: %d 条 -> %d 条(只读隔离被破坏)", len(before), len(after))
}
}
// seedIntentAndHabit 往共享域里放一条 L4 意图记录(主人原话)与一条
// 普通用户习惯记忆:受限上下文只允许后者出现。
func seedIntentAndHabit(t *testing.T, store *memory.Store) {
t.Helper()
seeds := []memory.MemoryItem{
{
PersonaID: "persona-a", Scope: memory.ScopeUser, Kind: memory.KindRelationship,
Content: "intent:isTask=true;taskKind=ops;confidence=0.9;text=帮我搞一下那个部署",
},
{
PersonaID: "persona-a", Scope: memory.ScopeUser, Kind: memory.KindRelationship,
Content: "用户习惯:喜欢手冲咖啡",
},
}
for _, it := range seeds {
if _, err := store.Add(context.Background(), it); err != nil {
t.Fatalf("seed memory: %v", err)
}
}
}