Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_lessons_test.go
ddCat 6892732b06 feat(agent): Agent 工作台 - 计划插话记忆、统一模型选择器并修复摘要与视频入库
将计划清单、运行中插话和个人记忆接入云端 Agent;全站模型选择器统一为左右二级菜单。画布摘要按预算截断不再整轮拒绝,首页视频未知宽高可入库。README 补充贡献者 morrow。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 20:21:25 +08:00

717 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"strings"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
func agentLessonTestRepo(t *testing.T) *repository.Repository {
t.Helper()
s, db, _, _ := creationTestService(t)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
return s.repo
}
func rememberLessonCall(arguments string) cloudAgentCall {
return cloudAgentCall{Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: "remember_lesson", Arguments: arguments}}
}
func TestAgentLessonScrubRejectsUserTraces(t *testing.T) {
rejected := []struct{ name, text string }{
{"链接", "参考 https://cdn.example.com/a.png 的写法"},
{"接口路径", "调用 /api/resources/xxx/file 拿文件"},
{"资源键", "storageKey 用 resource:abcdef123456 这种"},
{"运行或任务 ID", "运行 ag0480e47a0473a245bf6c6163c383b894 里跑通了"},
{"UUID", "节点 3f2504e0-4f89-11d3-9a0c-0305e82c3301 这么连"},
{"节点或连线 ID", "把 conn-abc123 保留下来"},
}
for _, item := range rejected {
if err := cloudAgentLessonScrub("lesson", item.text); err == nil {
t.Fatalf("%s:这条带用户痕迹的内容必须被拒:%s", item.name, item.text)
}
}
allowed := []string{
"视频模型只认分辨率参数,不要传画幅比例",
"分镜行挂资产必须指明具体是哪一行,否则会被服务端拒绝",
"同一轮里的多个生成调用要连续发出,中间插别的调用会退化成串行",
"把 @叮当猫在飞 连到分镜行",
}
for _, text := range allowed {
if err := cloudAgentLessonScrub("lesson", text); err != nil {
t.Fatalf("通用说法不该被拒:%s(%v)", text, err)
}
}
}
func TestAgentLessonPendingIsNotRecalledUntilApproved(t *testing.T) {
repo := agentLessonTestRepo(t)
now := time.Now()
entry := &model.AgentLesson{
ID: newID(), Topic: "video.duration", Situation: "竖屏视频生成",
Lesson: "用模型目录返回的时长,不要猜", Source: "generate_media",
Status: model.AgentLessonStatusPending, AuthorUserID: "user-a",
LastVerifiedAt: &now, CreatedAt: now, UpdatedAt: now,
}
if err := repo.Create(entry); err != nil {
t.Fatal(err)
}
approved, err := repo.ApprovedAgentLessons("user-a", 10)
if err != nil {
t.Fatal(err)
}
if len(approved) != 0 {
t.Fatalf("待审记忆不该出现在召回结果里:%+v", approved)
}
if err := repo.SetAgentLessonStatus("user-a", entry.ID, model.AgentLessonStatusApproved); err != nil {
t.Fatal(err)
}
approved, err = repo.ApprovedAgentLessons("user-a", 10)
if err != nil {
t.Fatal(err)
}
if len(approved) != 1 || approved[0].Topic != "video.duration" {
t.Fatalf("批准后应当可召回:%+v", approved)
}
other, err := repo.ApprovedAgentLessons("user-b", 10)
if err != nil {
t.Fatal(err)
}
if len(other) != 0 {
t.Fatalf("别人的已批准记忆不该出现在我的召回里:%+v", other)
}
}
func TestAgentLessonStoresSteps(t *testing.T) {
repo := agentLessonTestRepo(t)
steps := []model.AgentLessonStep{
{Tool: "canvas_get_state", Action: "读取画布,拿到真实行 id"},
{Tool: "canvas_apply_ops", Action: "connect_nodes 带上 rowId 把资产挂到该行", Note: "只连节点本身会被拒"},
}
raw, err := json.Marshal(steps)
if err != nil {
t.Fatal(err)
}
now := time.Now()
entry := &model.AgentLesson{
ID: newID(), Topic: "storyboard.row-connect", Situation: "给某一镜挂参考资产",
StepsJSON: string(raw), Status: model.AgentLessonStatusApproved,
AuthorUserID: "user-b", LastVerifiedAt: &now, CreatedAt: now, UpdatedAt: now,
}
if err := repo.Create(entry); err != nil {
t.Fatal(err)
}
got, err := repo.ApprovedAgentLessons("user-b", 10)
if err != nil || len(got) != 1 {
t.Fatalf("应取回一条:%+v(%v)", got, err)
}
var decoded []model.AgentLessonStep
if err := json.Unmarshal([]byte(got[0].StepsJSON), &decoded); err != nil {
t.Fatal(err)
}
if len(decoded) != 2 || decoded[1].Tool != "canvas_apply_ops" || !strings.Contains(decoded[1].Note, "会被拒") {
t.Fatalf("steps 未原样保留:%+v", decoded)
}
}
func TestAgentLessonAdminViewExposesParsedSteps(t *testing.T) {
s, db, _, _ := creationTestService(t)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
now := time.Now()
entry := &model.AgentLesson{
ID: newID(), Topic: "canvas.snapshot-hash", Category: "canvas", Situation: "连续写画布时报快照过期",
Lesson: "每次写成功后用返回的新 snapshotHash", StepsJSON: `[{"tool":"canvas_apply_ops","action":"写画布","note":"先读后写"}]`,
Status: model.AgentLessonStatusPending, AuthorUserID: "user-a",
LastVerifiedAt: &now, CreatedAt: now, UpdatedAt: now,
}
if err := s.repo.Create(entry); err != nil {
t.Fatal(err)
}
views, err := s.AdminAgentLessons("pending", "", "", 10)
if err != nil || len(views) != 1 {
t.Fatalf("管理端应看到待审条目:%+v(%v)", views, err)
}
if views[0].AuthorUserID != "user-a" {
t.Fatalf("管理端必须能看到作者以便追责:%+v", views[0])
}
if len(views[0].Steps) != 1 || views[0].Steps[0].Tool != "canvas_apply_ops" || views[0].Steps[0].Note != "先读后写" {
t.Fatalf("管理端必须能看到解析后的路线,不能被 json:\"-\" 藏掉:%+v", views[0].Steps)
}
raw, err := json.Marshal(views[0])
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"steps"`) || !strings.Contains(string(raw), "canvas_apply_ops") {
t.Fatalf("管理端 JSON 必须带 steps:%s", raw)
}
}
func TestAgentLessonUpsertDeduplicates(t *testing.T) {
repo := agentLessonTestRepo(t)
makeEntry := func(userID string) *model.AgentLesson {
now := time.Now()
return &model.AgentLesson{
ID: newID(), Topic: "video.duration", Situation: "竖屏视频生成",
Lesson: "用模型目录返回的时长", Status: model.AgentLessonStatusPending,
AuthorUserID: userID, LastVerifiedAt: &now, CreatedAt: now, UpdatedAt: now,
}
}
first := makeEntry("user-a")
if err := repo.Create(first); err != nil {
t.Fatal(err)
}
if err := repo.Create(makeEntry("user-c")); err != nil {
t.Fatal(err)
}
sameUserDup := makeEntry("user-a")
duplicate, err := cloudAgentFindDuplicateLesson(repo, "user-a", sameUserDup)
if err != nil {
t.Fatal(err)
}
if duplicate == nil || duplicate.ID != first.ID {
t.Fatalf("同一用户同内容应判重复:%+v", duplicate)
}
crossUser, err := cloudAgentFindDuplicateLesson(repo, "user-c", makeEntry("user-c"))
if err != nil {
t.Fatal(err)
}
if crossUser == nil {
t.Fatal("user-c 自己的第二条应能找到自己刚写入的那条")
}
allA, err := repo.UserAgentLessons("user-a", "", 50)
if err != nil {
t.Fatal(err)
}
allC, err := repo.UserAgentLessons("user-c", "", 50)
if err != nil {
t.Fatal(err)
}
if len(allA) != 1 || len(allC) != 1 {
t.Fatalf("两个用户各自应有一条,got a=%d c=%d", len(allA), len(allC))
}
}
func TestAgentLessonDedupIgnoresTopicSpelling(t *testing.T) {
repo := agentLessonTestRepo(t)
now := time.Now()
first := &model.AgentLesson{
ID: newID(), Topic: "视频生成 图片需先入库", Situation: "把图片当参考生成视频时",
Lesson: "参考图必须先存进资源库,否则模型拒绝该参考", Status: model.AgentLessonStatusPending,
AuthorUserID: "user-a", LastVerifiedAt: &now, CreatedAt: now, UpdatedAt: now,
}
if err := repo.Create(first); err != nil {
t.Fatal(err)
}
same := &model.AgentLesson{
ID: newID(), Topic: "视频生成 参考资产未入库 错误处理", Situation: "把图片当参考生成视频时",
Lesson: "参考图必须先存进资源库,否则模型拒绝该参考", Status: model.AgentLessonStatusPending,
AuthorUserID: "user-a", LastVerifiedAt: &now, CreatedAt: now, UpdatedAt: now,
}
duplicate, err := cloudAgentFindDuplicateLesson(repo, "user-a", same)
if err != nil {
t.Fatal(err)
}
if duplicate == nil || duplicate.ID != first.ID {
t.Fatalf("topic 不同但内容一致应判重复,got %+v", duplicate)
}
fresh := &model.AgentLesson{
ID: newID(), Topic: "视频生成 图片需先入库", Situation: "分镜行挂参考图时",
Lesson: "先建资产卡并生成形象图,再挂到行上", Status: model.AgentLessonStatusPending,
AuthorUserID: "user-a", LastVerifiedAt: &now, CreatedAt: now, UpdatedAt: now,
}
duplicate, err = cloudAgentFindDuplicateLesson(repo, "user-a", fresh)
if err != nil {
t.Fatal(err)
}
if duplicate != nil {
t.Fatalf("内容不同不该判重复,got %+v", duplicate)
}
}
func lessonWithSteps(topic, category string, tools ...string) model.AgentLesson {
steps := make([]model.AgentLessonStep, 0, len(tools))
for _, tool := range tools {
steps = append(steps, model.AgentLessonStep{Tool: tool, Action: "做点什么"})
}
raw, _ := json.Marshal(steps)
return model.AgentLesson{
ID: newID(), Topic: topic, Category: category, Situation: "某种情况",
Lesson: "某做法", StepsJSON: string(raw), Status: model.AgentLessonStatusApproved,
}
}
func TestLessonsBlockIsIndexNotDump(t *testing.T) {
view := cloudAgentLessonView{
Total: 9,
Counts: []repository.AgentLessonCategoryCount{
{Category: "storyboard", Count: 6},
{Category: "canvas", Count: 3},
},
Index: []model.AgentLesson{{Topic: "storyboard.row-connect", Situation: "给分镜行接线", Lesson: "不该出现的做法全文"}},
}
block := cloudAgentLessonsBlock(view)
if !strings.Contains(block, "共 9 条") || !strings.Contains(block, "分镜 6") || !strings.Contains(block, "storyboard.row-connect") {
t.Fatalf("应注入分类概览和索引:%s", block)
}
if strings.Contains(block, "不该出现的做法全文") || strings.Contains(block, ";做法:") {
t.Fatalf("记忆不得把做法全文塞进系统提示:%s", block)
}
if !strings.Contains(block, "recall_lessons") || !strings.Contains(block, "当前用户消息才是目标") {
t.Fatalf("应保留按需取全文入口:%s", block)
}
}
func TestLessonsBlockDoesNotInlineMatches(t *testing.T) {
view := cloudAgentLessonView{
Total: 1,
MatchedN: 1,
Index: []model.AgentLesson{lessonWithSteps("canvas.snapshot-hash", "canvas", "canvas_apply_ops")},
}
block := cloudAgentLessonsBlock(view)
if !strings.Contains(block, "canvas.snapshot-hash") || !strings.Contains(block, "可能相关") {
t.Fatalf("命中项应出现在索引里:%s", block)
}
if strings.Contains(block, "某做法") || strings.Contains(block, ";做法:") {
t.Fatalf("命中项也不该自动注入全文:%s", block)
}
}
func TestLessonsBlockRanksMatchedFirst(t *testing.T) {
lessons := []model.AgentLesson{
lessonWithSteps("video.duration", "video"),
lessonWithSteps("canvas.snapshot-hash", "canvas", "canvas_apply_ops"),
}
index, matchedN := cloudAgentPickLessonIndex(lessons, "把资产挂上,正在调用 canvas_apply_ops", 20)
if matchedN != 1 || len(index) != 2 || index[0].Topic != "canvas.snapshot-hash" {
t.Fatalf("命中项应排在索引前面:matchedN=%d topics=%v", matchedN, topicsOf(index))
}
}
func TestLessonsBlockEmptyLibrary(t *testing.T) {
block := cloudAgentLessonsBlock(cloudAgentLessonView{})
if !strings.Contains(block, "还没有已批准记忆") {
t.Fatalf("空库提示缺失:%s", block)
}
}
func TestLessonMatchesByRouteTools(t *testing.T) {
lesson := lessonWithSteps("storyboard.asset-slot-reuse", "storyboard", "canvas_apply_ops", "canvas_read_storyboard")
if !cloudAgentLessonMatchesTask(lesson, "把资产挂到分镜行上,正在调用 canvas_apply_ops") {
t.Fatal("路线工具出现在任务文本里,应当命中")
}
if cloudAgentLessonMatchesTask(lesson, "帮我写一首歌,编曲用钢琴") {
t.Fatal("毫不相关的任务不该命中")
}
}
func TestLessonMatchIgnoresGenericTokens(t *testing.T) {
lesson := lessonWithSteps("video", "video")
if cloudAgentLessonMatchesTask(lesson, "我要生成一个视频") {
t.Fatal("只有泛词命中时不该算相关")
}
}
func TestLessonTaskTextCoversPromptPlanAndCalls(t *testing.T) {
state := &cloudAgentRuntime{
Request: CloudAgentRequest{Prompt: "做个手机广告"},
Plan: []cloudAgentPlanItem{{ID: "1", Title: "生成角色资产", Status: "doing"}},
Calls: []cloudAgentCall{{Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: "generate_media"}}},
}
text := cloudAgentLessonTaskText(state)
for _, want := range []string{"手机广告", "生成角色资产", "generate_media"} {
if !strings.Contains(text, want) {
t.Fatalf("任务文本缺少 %q:%s", want, text)
}
}
}
func TestLessonCategoryIsControlled(t *testing.T) {
if _, ok := cloudAgentLessonCategoryOf("storyboard"); !ok {
t.Fatal("合法分类被拒")
}
if _, ok := cloudAgentLessonCategoryOf("我自己编的类"); ok {
t.Fatal("词表外的分类不该通过")
}
if cloudAgentNormalizeLessonCategory("乱写的") != "other" {
t.Fatal("未知分类应归一为 other")
}
if len(cloudAgentLessonCategoryKeys()) != len(cloudAgentLessonCategories) {
t.Fatal("enum 与词表不同源")
}
}
func TestRecallResultIndexVsFull(t *testing.T) {
lesson := lessonWithSteps("video.model-duration", "video", "model_list")
index := cloudAgentLessonResult([]model.AgentLesson{lesson}, false)
items := index["lessons"].([]map[string]any)
if _, has := items[0]["steps"]; has {
t.Fatal("索引层不该带路线")
}
if _, has := items[0]["lesson"]; has {
t.Fatal("索引层不该带做法正文")
}
if items[0]["topic"] != "video.model-duration" || items[0]["category"] != "video" {
t.Fatalf("索引项缺字段:%+v", items[0])
}
if index["mode"] != "index" {
t.Fatalf("模式标记不对:%v", index["mode"])
}
full := cloudAgentLessonResult([]model.AgentLesson{lesson}, true)
fullItems := full["lessons"].([]map[string]any)
if _, has := fullItems[0]["steps"]; !has {
t.Fatal("正文层必须带路线")
}
if full["mode"] != "full" {
t.Fatalf("模式标记不对:%v", full["mode"])
}
}
func TestLessonSearchTokensSplitsOnSpacesAndPunctuation(t *testing.T) {
got := cloudAgentLessonSearchTokens("分镜 合并 视频 合成 拼接 one take")
want := []string{"分镜", "合并", "视频", "合成", "拼接", "one", "take"}
if len(got) != len(want) {
t.Fatalf("切词结果 %v,期望 %v", got, want)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("切词结果 %v,期望 %v", got, want)
}
}
got = cloudAgentLessonSearchTokens("视频、分辨率;视频!a")
for _, token := range got {
if len([]rune(token)) < 2 {
t.Fatalf("单字词应当被丢掉:%v", got)
}
}
if len(got) != 2 {
t.Fatalf("标点应切开且去重,得到 %v", got)
}
}
func TestLessonSearchTokensAreCapped(t *testing.T) {
long := ""
for index := 0; index < 20; index++ {
long += "词条" + string(rune('a'+index)) + " "
}
if got := cloudAgentLessonSearchTokens(long); len(got) > cloudAgentLessonSearchTokenMax {
t.Fatalf("词数应被截到 %d,得到 %d", cloudAgentLessonSearchTokenMax, len(got))
}
}
func TestLessonMatchScoreRanksByHit(t *testing.T) {
tokens := cloudAgentLessonSearchTokens("视频 合并 拼接")
strong := model.AgentLesson{Topic: "video.merge-concat-not-a-feature", Situation: "用户要把多条镜头视频合并成一条成片"}
weak := model.AgentLesson{Topic: "video.model-duration", Situation: "选一个全批都能出片的模型"}
unrelated := model.AgentLesson{Topic: "canvas.snapshot-hash", Situation: "快照过期"}
if cloudAgentLessonMatchScore(strong, tokens) <= cloudAgentLessonMatchScore(weak, tokens) {
t.Fatal("命中更多的应当分更高")
}
if cloudAgentLessonMatchScore(unrelated, tokens) != 0 {
t.Fatal("不相干的应当是 0 分")
}
}
func TestLessonSearchFindsMergeLessonByRealKeyword(t *testing.T) {
repo := agentLessonTestRepo(t)
now := time.Now()
seed := []model.AgentLesson{
{ID: newID(), Topic: "video.merge-concat-not-a-feature", Category: "video",
Situation: "用户要求把多条已出好的镜头视频合并成一条成片",
Lesson: "分镜脚本节点没有合并视频的动作,合片属于后期剪辑", Status: model.AgentLessonStatusApproved, AuthorUserID: "user-a"},
{ID: newID(), Topic: "canvas.snapshot-hash", Category: "canvas",
Situation: "连续写画布时反复报快照过期", Lesson: "每次写成功后用返回的新 snapshotHash",
Status: model.AgentLessonStatusApproved, AuthorUserID: "user-a"},
{ID: newID(), Topic: "storyboard.asset-slot-reuse", Category: "storyboard",
Situation: "分镜行的资产格", Lesson: "只给空缺格生成资产", Status: model.AgentLessonStatusApproved, AuthorUserID: "user-a"},
}
for index := range seed {
seed[index].CreatedAt, seed[index].UpdatedAt, seed[index].LastVerifiedAt = now, now, &now
if err := repo.Create(&seed[index]); err != nil {
t.Fatal(err)
}
}
got, err := cloudAgentSearchLessons(repo, "user-a", "分镜 合并 视频 合成 拼接 one take", 5)
if err != nil {
t.Fatal(err)
}
if len(got) == 0 {
t.Fatal("切词后应当能命中")
}
if got[0].Topic != "video.merge-concat-not-a-feature" {
t.Fatalf("讲「合并视频」的那条应当排第一,实际第一是 %q(全部:%+v)", got[0].Topic, topicsOf(got))
}
}
func TestLessonSearchNoMatchReturnsActionableError(t *testing.T) {
repo := agentLessonTestRepo(t)
now := time.Now()
entry := model.AgentLesson{ID: newID(), Topic: "canvas.snapshot-hash", Category: "canvas",
Situation: "快照过期", Lesson: "用新哈希", Status: model.AgentLessonStatusApproved,
AuthorUserID: "user-a", CreatedAt: now, UpdatedAt: now, LastVerifiedAt: &now}
if err := repo.Create(&entry); err != nil {
t.Fatal(err)
}
_, err := cloudAgentRecallLessons(repo, "user-a", cloudAgentCall{
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: "recall_lessons", Arguments: `{"keyword":"完全不存在的词条xyzzy"}`},
})
if err == nil {
t.Fatal("搜不到应当报错")
}
if !strings.Contains(err.Error(), "列索引") {
t.Fatalf("报错要给出下一步,实际:%v", err)
}
}
func topicsOf(lessons []model.AgentLesson) []string {
out := make([]string, 0, len(lessons))
for _, lesson := range lessons {
out = append(out, lesson.Topic)
}
return out
}
func TestLessonsBlockDoesNotAutoInject(t *testing.T) {
s, db, _, _ := creationTestService(t)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
now := time.Now()
matched := model.AgentLesson{
ID: newID(), Topic: "canvas.snapshot-hash", Category: "canvas",
Situation: "写画布时报快照过期", Lesson: "用返回的新 snapshotHash",
StepsJSON: `[{"tool":"canvas_apply_ops","action":"写画布"}]`,
Status: model.AgentLessonStatusApproved, AuthorUserID: "user-a",
}
matched.CreatedAt, matched.UpdatedAt, matched.LastVerifiedAt = now, now, &now
if err := s.repo.Create(&matched); err != nil {
t.Fatal(err)
}
block := s.cloudAgentLessonsBlock("user-a", "把资产挂上,正在调用 canvas_apply_ops")
if !strings.Contains(block, "canvas.snapshot-hash") {
t.Fatalf("相关记忆应出现在索引里:%s", block)
}
if strings.Contains(block, "用返回的新 snapshotHash") {
t.Fatalf("相关记忆也不该自动注入做法全文:%s", block)
}
got, err := s.repo.AgentLessonForUser("user-a", matched.ID)
if err != nil {
t.Fatal(err)
}
if got.Injected != 0 || got.Hits != 0 {
t.Fatalf("未主动查阅时 injected/hits 都应是 0:injected=%d hits=%d", got.Injected, got.Hits)
}
}
func TestRememberLessonBlockedInReadOnlyAndWithoutRealWork(t *testing.T) {
repo := agentLessonTestRepo(t)
call := rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`)
readOnly := &cloudAgentRuntime{
Request: CloudAgentRequest{PermissionMode: "read_only"},
Events: []CloudAgentEvent{{Type: "tool_completed", Payload: map[string]any{"toolName": "canvas_apply_ops"}}},
}
if _, err := cloudAgentRememberLesson(repo, "user", readOnly, call); err == nil || !strings.Contains(err.Error(), "只读模式") {
t.Fatalf("只读模式必须拒绝写入:%v", err)
}
if cloudAgentToolAllowed(agentTestRequest(), "remember_lesson") {
t.Fatal("只读工具清单不该出现 remember_lesson")
}
if !cloudAgentToolAllowed(agentTestRequest(), "recall_lessons") {
t.Fatal("只读模式仍应能查阅已批准经验")
}
idle := &cloudAgentRuntime{
Request: CloudAgentRequest{PermissionMode: "auto"},
Events: []CloudAgentEvent{{Type: "tool_completed", Payload: map[string]any{"toolName": "skills_load"}}},
}
if _, err := cloudAgentRememberLesson(repo, "user", idle, call); err == nil || !strings.Contains(err.Error(), "没有可沉淀") {
t.Fatalf("仅 skills_load 不能打开写入闸:%v", err)
}
}
func TestRememberLessonCapsWritesPerRun(t *testing.T) {
repo := agentLessonTestRepo(t)
events := []CloudAgentEvent{{Type: "tool_completed", Payload: map[string]any{"toolName": "canvas_apply_ops"}}}
for i := 0; i < cloudAgentRememberLessonMaxPerRun; i++ {
events = append(events, CloudAgentEvent{Type: "tool_completed", Payload: map[string]any{"toolName": "remember_lesson"}})
}
state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto"}, Events: events}
_, err := cloudAgentRememberLesson(repo, "user", state, rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`))
if err == nil || !strings.Contains(err.Error(), "最多记录") {
t.Fatalf("超出本轮条数上限应拒绝:%v", err)
}
}
func TestAttachLessonsDoesNotAppendUserNudge(t *testing.T) {
s, db, _, _ := creationTestService(t)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
canonical := canonicalAgentRequest{
SystemPrompt: "system",
Messages: []map[string]any{{"role": "user", "content": "帮我做分镜"}},
}
s.attachCloudAgentLessons(&canonical, "user-a", "帮我做分镜")
if len(canonical.Messages) != 1 || stringField(canonical.Messages[0], "role") != "user" {
t.Fatalf("经验库不得再塞一条假用户催办:%+v", canonical.Messages)
}
if !strings.Contains(canonical.SystemPrompt, cloudAgentLessonBlockMarker) {
t.Fatalf("记忆入口应只出现在系统提示:%s", canonical.SystemPrompt)
}
if strings.Contains(canonical.SystemPrompt, ";做法:") {
t.Fatalf("系统提示不得带记忆全文:%s", canonical.SystemPrompt)
}
if stripped := stripCloudAgentLessonBlock(canonical.SystemPrompt); stripped != "system" {
t.Fatalf("剥离后应回到原系统提示,got %q", stripped)
}
frozen := canonical.SystemPrompt
s.attachCloudAgentLessons(&canonical, "user-a", "完全不同的后续任务 canvas_apply_ops")
if canonical.SystemPrompt != frozen {
t.Fatalf("同轮不得重算记忆块,否则会打爆前缀缓存:%s", canonical.SystemPrompt)
}
}
func TestUserAgentMemoryCRUDAndIsolation(t *testing.T) {
s, db, _, _ := creationTestService(t)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
created, err := s.CreateUserAgentMemory("user-a", AgentMemoryRequest{
Topic: "canvas.snapshot-hash", Category: "canvas", Situation: "写画布时报快照过期", Lesson: "用返回的新 snapshotHash",
})
if err != nil {
t.Fatal(err)
}
if created.Status != model.AgentLessonStatusApproved {
t.Fatalf("用户手动添加应直接生效:%s", created.Status)
}
other, err := s.UserAgentMemories("user-b", "", 20)
if err != nil {
t.Fatal(err)
}
if len(other) != 0 {
t.Fatalf("不能读到别人的记忆:%+v", other)
}
if _, err := s.UpdateUserAgentMemory("user-b", created.ID, AgentMemoryRequest{
Topic: "canvas.snapshot-hash", Category: "canvas", Situation: "写画布时报快照过期", Lesson: "偷改别人的记忆",
}); err == nil {
t.Fatal("不能改别人的记忆")
}
if err := s.DeleteUserAgentMemory("user-b", created.ID); err == nil {
t.Fatal("不能删别人的记忆")
}
own, err := s.UserAgentMemories("user-a", "approved", 20)
if err != nil || len(own) != 1 {
t.Fatalf("本人应看到自己添加的记忆:%+v(%v)", own, err)
}
}
func TestUserAgentMemoryApproveIsOwnerOnly(t *testing.T) {
s, db, _, _ := creationTestService(t)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
now := time.Now()
entry := &model.AgentLesson{
ID: newID(), Topic: "video.duration", Category: "video", Situation: "竖屏视频生成",
Lesson: "用模型目录返回的时长", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a",
CreatedAt: now, UpdatedAt: now, LastVerifiedAt: &now,
}
if err := s.repo.Create(entry); err != nil {
t.Fatal(err)
}
if err := s.DecideUserAgentMemory("user-b", entry.ID, "approve"); err == nil {
t.Fatal("不能批准别人的待审记忆")
}
if err := s.DecideUserAgentMemory("user-a", entry.ID, "approve"); err != nil {
t.Fatal(err)
}
got, err := s.repo.ApprovedAgentLessons("user-a", 10)
if err != nil || len(got) != 1 {
t.Fatalf("本人批准后应可召回:%+v(%v)", got, err)
}
}
func TestUserAgentMemoryExportImport(t *testing.T) {
s, db, _, _ := creationTestService(t)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
if _, err := s.CreateUserAgentMemory("user-a", AgentMemoryRequest{
Topic: "canvas.snapshot-hash", Category: "canvas", Situation: "写画布时报快照过期", Lesson: "用返回的新 snapshotHash",
}); err != nil {
t.Fatal(err)
}
bundle, err := s.ExportUserAgentMemories("user-a")
if err != nil || bundle.Kind != "agent-memories" || len(bundle.Memories) != 1 {
t.Fatalf("导出失败:%+v(%v)", bundle, err)
}
result, err := s.ImportUserAgentMemories("user-b", bundle)
if err != nil || result.Imported != 1 {
t.Fatalf("导入到另一用户应新增:%+v(%v)", result, err)
}
again, err := s.ImportUserAgentMemories("user-b", bundle)
if err != nil || again.Merged != 1 {
t.Fatalf("重复导入应合并:%+v(%v)", again, err)
}
if _, err := s.ImportUserAgentMemories("user-a", AgentMemoryBundle{Kind: "not-memories", Memories: bundle.Memories}); err == nil {
t.Fatal("错误 kind 必须拒绝")
}
}
func TestAdminAgentLessonsFiltersByUser(t *testing.T) {
s, db, _, _ := creationTestService(t)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
now := time.Now()
for _, userID := range []string{"user-a", "user-b"} {
entry := &model.AgentLesson{
ID: newID(), Topic: "canvas.snapshot-hash-" + userID, Category: "canvas", Situation: "写画布时报快照过期",
Lesson: "用返回的新 snapshotHash", Status: model.AgentLessonStatusApproved, AuthorUserID: userID,
CreatedAt: now, UpdatedAt: now, LastVerifiedAt: &now,
}
if err := s.repo.Create(entry); err != nil {
t.Fatal(err)
}
}
if err := s.repo.Create(&model.User{ID: "user-a", Username: "alice", DisplayName: "爱丽丝"}); err != nil {
t.Fatal(err)
}
filtered, err := s.AdminAgentLessons("", "user-a", "", 20)
if err != nil || len(filtered) != 1 || filtered[0].AuthorUserID != "user-a" {
t.Fatalf("应按用户筛选:%+v(%v)", filtered, err)
}
if filtered[0].AuthorUsername != "alice" {
t.Fatalf("管理端应带上用户名:%+v", filtered[0])
}
byName, err := s.AdminAgentLessons("", "", "alice", 20)
if err != nil || len(byName) != 1 || byName[0].AuthorUserID != "user-a" {
t.Fatalf("应按用户名搜索:%+v(%v)", byName, err)
}
all, err := s.AdminAgentLessons("", "", "", 20)
if err != nil || len(all) != 2 {
t.Fatalf("不筛选时应看到全部:%d(%v)", len(all), err)
}
}