Files
Cyrene_v2/internal/runtime/transcript/transcript_test.go
T
AskaEth 9b903a89bc
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime): IM 主人私聊补历史转录(与 Web 主会话同源:抽共享 transcript 包 + OwnerEnvFor 单入口)
2026-10-05 11:36:18 +08:00

168 lines
5.9 KiB
Go

package transcript
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住共享转录映射的行为(网关与渠道桥的 IM 主人私聊回合都经它读历史):
// 顺序最旧优先、角色翻译、说话角色归属、文档引用全量还原、条数上限取自本包的
// ContextCount,以及「读不出来就退化为无转录」的降级口径。
// fakeReader 是只实现 Recent 的窄历史视图,并记录每次请求的会话与条数上限。
type fakeReader struct {
rows []history.Message
err error
session string
n int
callCount int
}
func (f *fakeReader) Recent(_ context.Context, sessionID string, n int) ([]history.Message, error) {
f.session = sessionID
f.n = n
f.callCount++
if f.err != nil {
return nil, f.err
}
out := make([]history.Message, 0, len(f.rows))
for _, m := range f.rows {
if m.SessionID == sessionID {
out = append(out, m)
}
}
if n <= 0 {
return nil, nil
}
if len(out) > n {
out = out[len(out)-n:]
}
return out, nil
}
// textOf 拼接一条模型消息里的文本部件。
func textOf(m model.Message) string {
out := ""
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
out += c.Text
}
}
return out
}
func TestReadMapsRecentRowsOldestFirstWithSpeakerAttribution(t *testing.T) {
t.Parallel()
rd := &fakeReader{rows: []history.Message{
{ID: "m1", SessionID: "web", Role: history.RoleUser, Content: "喵"},
{ID: "m2", SessionID: "web", Role: history.RoleAssistant, Content: "又用喵来敲门啦", PersonaID: "cyrene"},
{ID: "m3", SessionID: "web", Role: history.RoleSystem, Content: "系统行"},
{ID: "x1", SessionID: "sess-other", Role: history.RoleUser, Content: "别的会话"},
}}
got := Read(context.Background(), rd, "web")
if len(got) != 3 {
t.Fatalf("messages = %d, want 3 (only the requested session): %+v", len(got), got)
}
if got[0].Role != model.RoleUser || textOf(got[0]) != "喵" {
t.Fatalf("messages[0] = %+v, want the oldest user turn first", got[0])
}
// 助手话语带上说出它的角色,非助手消息保持已存发送者标签。
if got[1].Role != model.RoleAssistant || got[1].SenderName != "cyrene" {
t.Fatalf("messages[1] = %+v, want her reply attributed to cyrene", got[1])
}
if got[2].Role != model.RoleSystem {
t.Fatalf("messages[2] = %+v, want the persisted system row", got[2])
}
}
func TestReadWindowIsTheSharedContextCount(t *testing.T) {
t.Parallel()
rows := make([]history.Message, 0, ContextCount+5)
for i := 0; i < ContextCount+5; i++ {
rows = append(rows, history.Message{SessionID: "web", Role: history.RoleUser, Content: "第" + string(rune('a'+i)) + "句"})
}
rd := &fakeReader{rows: rows}
got := Read(context.Background(), rd, "web")
// 条数上限只有一个来源:本包的 ContextCount(网关与 IM 车道都从这里取,不许各自造数)。
if rd.n != ContextCount {
t.Fatalf("requested window = %d, want the shared ContextCount (%d)", rd.n, ContextCount)
}
if len(got) != ContextCount {
t.Fatalf("messages = %d, want %d (the newest window)", len(got), ContextCount)
}
}
func TestReadDegradesToNil(t *testing.T) {
t.Parallel()
if got := Read(context.Background(), nil, "web"); got != nil {
t.Fatalf("nil store = %+v, want nil", got)
}
rd := &fakeReader{rows: []history.Message{{SessionID: "web", Role: history.RoleUser, Content: "喵"}}}
if got := Read(context.Background(), rd, ""); got != nil {
t.Fatalf("empty session = %+v, want nil", got)
}
if rd.callCount != 0 {
t.Fatalf("empty session read the store %d times, want 0", rd.callCount)
}
bad := &fakeReader{err: errors.New("boom")}
if got := Read(context.Background(), bad, "web"); got != nil {
t.Fatalf("read error = %+v, want nil (the turn must still run)", got)
}
}
func TestMessageRestoresEveryDocumentPartOnUserRows(t *testing.T) {
t.Parallel()
raw, err := history.EncodeDocuments([]history.DocumentRef{
{Name: "0123456789abcdef0123456789abcdef.txt", MIME: "text/plain", Size: 12},
{Name: "fedcba9876543210fedcba9876543210.pdf", MIME: "application/pdf", Size: 34},
})
if err != nil {
t.Fatalf("EncodeDocuments: %v", err)
}
msg := Message(history.Message{ID: "m1", Role: history.RoleUser, Content: "读一下", Document: raw})
if len(msg.Content) != 3 {
t.Fatalf("parts = %d, want text + 2 documents: %+v", len(msg.Content), msg.Content)
}
for i, want := range []history.DocumentRef{
{Name: "0123456789abcdef0123456789abcdef.txt", MIME: "text/plain", Size: 12},
{Name: "fedcba9876543210fedcba9876543210.pdf", MIME: "application/pdf", Size: 34},
} {
part := msg.Content[i+1]
if part.DocumentURL != DocumentPathPrefix+want.Name || part.DocumentMIME != want.MIME {
t.Fatalf("document part[%d] = %+v, want %s%s", i, part, DocumentPathPrefix, want.Name)
}
}
// 非用户行上的文档部件会让编码器 fail-loud,因此刻意省掉(坏数据不该卡死会话)。
other := Message(history.Message{ID: "m2", Role: history.RoleAssistant, Content: "好", Document: raw})
for _, c := range other.Content {
if c.DocumentURL != "" {
t.Fatalf("assistant row carries a document part: %+v", c)
}
}
// 坏列不 panic、也不产出部件(调用方另行记录 WARN)。
broken := Message(history.Message{ID: "m3", Role: history.RoleUser, Content: "坏列", Document: "{not json"})
if len(broken.Content) != 1 {
t.Fatalf("broken document column produced %d parts, want just the text: %+v", len(broken.Content), broken.Content)
}
}
func TestMessageRestoresImages(t *testing.T) {
t.Parallel()
msg := Message(history.Message{ID: "m1", Role: history.RoleUser, Content: "看图", Image: "data:image/png;base64,AAAA"})
if len(msg.Content) != 2 || msg.Content[1].ImageURL != "data:image/png;base64,AAAA" {
t.Fatalf("image part = %+v, want the stored data URI restored", msg.Content)
}
}