feat(history): 会话消息持久化 + 主/次级会话(design/16)

- runtime/history: Store + HistoryStore 接口(Append/List 游标分页/Recent/DeleteSession/NewSubSession/ListSessions),主会话固定 web、次级 web:<uuid>
- db 迁移 0003: chat_sessions + chat_messages + idx_chat_msgs_session_created(事务幂等)
- gateway: GET/POST /sessions、GET /sessions/{id}/messages 热分页、DELETE /sessions/{id}(主不可删 403);Deps.History 可空注入
- 编排层落库: session_persist.go(submitInternal 写用户消息、completeTurn 写助手消息,拒绝的消息不落库)
- WS: wsEvent 加 sessionId 定向 + history 回放帧(握手上行后回放主会话最新 50 条,thinking 剥离)
- wire.go 装配 History 到 gateway.Deps.History
- 端到端实测: POST /sessions 建次级 201 / GET /sessions 列表 / POST /chat 后 /messages 读到 user+assistant / DELETE 次级成功 / DELETE 主会话 403
This commit is contained in:
Aska committed 2026-09-07 03:52:43 +08:00
1 parent cc3eecacac
commit 48421923f6
15 files changed
+1305 -18

No files matched your search

@@ -8,6 +8,7 @@ import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -149,6 +150,89 @@ func readOnlyEnv() loop.SessionEnv {
return loop.SessionEnv{MemWritable: false}
}
// fakeHistory records every Append so tests assert the orchestrator persists the
// user stream and the assistant reply into the chat-history store.
type fakeHistory struct {
mu sync.Mutex
appends []history.Message
}
func (f *fakeHistory) Append(_ context.Context, msg history.Message) error {
f.mu.Lock()
f.appends = append(f.appends, msg)
f.mu.Unlock()
return nil
}
func (f *fakeHistory) List(context.Context, string, int64, int) ([]history.Message, error) {
return nil, nil
}
func (f *fakeHistory) Recent(context.Context, string, int) ([]history.Message, error) {
return nil, nil
}
func (f *fakeHistory) DeleteSession(context.Context, string) error {
return nil
}
func (f *fakeHistory) NewSubSession(context.Context, string) (string, error) {
return "", nil
}
func (f *fakeHistory) ListSessions(context.Context) ([]history.SessionMeta, error) {
return nil, nil
}
func (f *fakeHistory) records() []history.Message {
f.mu.Lock()
defer f.mu.Unlock()
return append([]history.Message(nil), f.appends...)
}
func TestSubmitPersistsUserAndAssistant(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{
Run: run.Run,
Log: slog.Default(),
History: hist,
})
run.tickets <- struct{}{}
sr, res := o.SubmitSync(context.Background(), "web", []model.Message{userMsg("你好")}, loop.OwnerEnv())
if !sr.Accepted || !sr.Immediate {
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
}
if res == nil {
t.Fatal("sync result is nil")
}
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
if recs[0].SessionID != "web" || recs[0].Role != history.RoleUser || recs[0].Content != "你好" {
t.Fatalf("user record = %+v", recs[0])
}
if recs[1].SessionID != "web" || recs[1].Role != history.RoleAssistant || recs[1].Content == "" {
t.Fatalf("assistant record = %+v", recs[1])
}
}
func TestSubmitDoesNotPersistRejectedTurn(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
o := NewOrchestrator(OrchestratorOptions{Run: newGatedRunner(0).Run, Log: slog.Default(), History: hist, MaxPending: 1})
// Empty turn is rejected before any persistence.
sr := o.Submit(context.Background(), "web", nil, loop.OwnerEnv())
if sr.Accepted || !sr.Empty {
t.Fatalf("empty submit = %+v, want Empty reject", sr)
}
if n := len(hist.records()); n != 0 {
t.Fatalf("appends = %d, want 0 for a rejected turn", n)
}
}
func TestSubmitIdleStartsImmediate(t *testing.T) {
t.Parallel()
run := newGatedRunner(1)