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:
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)
|
||||
|
||||
Reference in new issue
Block a user