feat(agent): Agent 工作台 - 计划插话记忆、统一模型选择器并修复摘要与视频入库
将计划清单、运行中插话和个人记忆接入云端 Agent;全站模型选择器统一为左右二级菜单。画布摘要按预算截断不再整轮拒绝,首页视频未知宽高可入库。README 补充贡献者 morrow。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
219
backend/internal/app/cloud_agent_memory_compact_test.go
Normal file
219
backend/internal/app/cloud_agent_memory_compact_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestAgentMemoryCompactMergesAndRewritesOwnedLessons(t *testing.T) {
|
||||
s, db, _, _ := creationTestService(t)
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
first, err := s.CreateUserAgentMemory("user-a", AgentMemoryRequest{
|
||||
Topic: "video.duration-a", Category: "video", Situation: "竖屏视频时长参数",
|
||||
Lesson: "用模型目录返回的时长,不要猜秒数",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := s.CreateUserAgentMemory("user-a", AgentMemoryRequest{
|
||||
Topic: "video.duration-b", Category: "video", Situation: "同样是竖屏视频时长",
|
||||
Lesson: "时长只能用来自目录的合法值",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewrite, err := s.CreateUserAgentMemory("user-a", AgentMemoryRequest{
|
||||
Topic: "canvas.snapshot", Category: "canvas", Situation: "连续写画布时报快照过期",
|
||||
Lesson: "写成功后要用返回的新 snapshotHash 再写下一次",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := s.CreateUserAgentMemory("user-b", AgentMemoryRequest{
|
||||
Topic: "video.duration-other", Category: "video", Situation: "别人的竖屏视频时长",
|
||||
Lesson: "别人的记忆不能被合并进来",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
summary, err := s.applyAgentMemoryCompactPlan("user-a", agentMemoryCompactPlan{
|
||||
Merges: []agentMemoryCompactMerge{{
|
||||
IDs: []string{first.ID, second.ID, other.ID}, Topic: "video.duration", Category: "video",
|
||||
Situation: "竖屏视频生成", Lesson: "只用模型目录返回的时长", Source: "compact",
|
||||
}},
|
||||
Rewrites: []agentMemoryCompactRewrite{{
|
||||
ID: rewrite.ID, Topic: "canvas.snapshot-hash", Category: "canvas",
|
||||
Situation: "连续写画布", Lesson: "每次写成功后用返回的新快照哈希", Source: "compact",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.Merged != 0 || summary.Rewritten != 1 || summary.Removed != 0 || summary.Skipped != 1 {
|
||||
t.Fatalf("含他人 id 的 merge 必须整组跳过,rewrite 仍可应用:%+v", summary)
|
||||
}
|
||||
|
||||
summary, err = s.applyAgentMemoryCompactPlan("user-a", agentMemoryCompactPlan{
|
||||
Merges: []agentMemoryCompactMerge{{
|
||||
IDs: []string{first.ID, second.ID}, Topic: "video.duration", Category: "video",
|
||||
Situation: "竖屏视频生成", Lesson: "只用模型目录返回的时长", Source: "compact",
|
||||
}},
|
||||
Rewrites: []agentMemoryCompactRewrite{{
|
||||
ID: rewrite.ID, Topic: "canvas.snapshot-hash", Category: "canvas",
|
||||
Situation: "连续写画布", Lesson: "每次写成功后用返回的新快照哈希", Source: "compact",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.Merged != 1 || summary.Rewritten != 1 || summary.Removed != 1 {
|
||||
t.Fatalf("应合并一条、改写一条、删除一条:%+v", summary)
|
||||
}
|
||||
|
||||
owned, err := s.repo.ApprovedAgentLessons("user-a", 10)
|
||||
if err != nil || len(owned) != 2 {
|
||||
t.Fatalf("压缩后本人应剩 2 条:%+v(%v)", owned, err)
|
||||
}
|
||||
byID := map[string]model.AgentLesson{}
|
||||
for _, lesson := range owned {
|
||||
byID[lesson.ID] = lesson
|
||||
}
|
||||
if _, ok := byID[second.ID]; ok {
|
||||
t.Fatalf("被合并的源记忆应删除:%+v", owned)
|
||||
}
|
||||
keep := byID[first.ID]
|
||||
if keep.Topic != "video.duration" || keep.Lesson != "只用模型目录返回的时长" {
|
||||
t.Fatalf("合并应写回保留的第一条:%+v", keep)
|
||||
}
|
||||
rewritten := byID[rewrite.ID]
|
||||
if rewritten.Topic != "canvas.snapshot-hash" || rewritten.Lesson != "每次写成功后用返回的新快照哈希" {
|
||||
t.Fatalf("改写应更新原文:%+v", rewritten)
|
||||
}
|
||||
|
||||
others, err := s.repo.ApprovedAgentLessons("user-b", 10)
|
||||
if err != nil || len(others) != 1 || others[0].ID != other.ID {
|
||||
t.Fatalf("别人的记忆必须原样保留:%+v(%v)", others, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteAgentMemoryCompactTaskRequiresMatchingTask(t *testing.T) {
|
||||
s, db, _, _ := creationTestService(t)
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
first, err := s.CreateUserAgentMemory("user-a", AgentMemoryRequest{
|
||||
Topic: "canvas.write-a", Category: "canvas", Situation: "写画布前先读状态",
|
||||
Lesson: "先读后写,避免快照过期",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := s.CreateUserAgentMemory("user-a", AgentMemoryRequest{
|
||||
Topic: "canvas.write-b", Category: "canvas", Situation: "写画布前先核对快照",
|
||||
Lesson: "每次写之前都再读一次",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting, err := s.ensureAgentMemorySetting("user-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setting.CompactTaskID = "task-real"
|
||||
setting.LastStatus = model.AgentMemoryCompactStatusQueued
|
||||
if err := s.repo.SaveAgentMemorySetting(setting); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
planText := `{"merges":[{"ids":["` + first.ID + `","` + second.ID + `"],"topic":"canvas.write","category":"canvas","situation":"写画布","lesson":"先读后写","source":"compact"}],"rewrites":[]}`
|
||||
s.noteAgentMemoryCompactTask(model.Task{
|
||||
ID: "task-other", UserID: "user-a", Operation: cloudAgentMemoryCompactOp,
|
||||
}, map[string]any{"text": planText}, nil)
|
||||
|
||||
owned, err := s.repo.ApprovedAgentLessons("user-a", 10)
|
||||
if err != nil || len(owned) != 2 {
|
||||
t.Fatalf("任务 ID 对不上时不得应用压缩:%+v(%v)", owned, err)
|
||||
}
|
||||
|
||||
s.noteAgentMemoryCompactTask(model.Task{
|
||||
ID: "task-real", UserID: "user-a", Operation: cloudAgentMemoryCompactOp,
|
||||
}, map[string]any{"text": "```json\n" + planText + "\n```"}, nil)
|
||||
owned, err = s.repo.ApprovedAgentLessons("user-a", 10)
|
||||
if err != nil || len(owned) != 1 || owned[0].ID != first.ID {
|
||||
t.Fatalf("匹配任务成功后应合并:%+v(%v)", owned, err)
|
||||
}
|
||||
updated, err := s.repo.AgentMemorySetting("user-a")
|
||||
if err != nil || updated.LastStatus != model.AgentMemoryCompactStatusSucceeded || updated.LastSummaryJSON == "" {
|
||||
t.Fatalf("成功后应记下状态:%+v(%v)", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserAgentMemorySettingNormalizesInterval(t *testing.T) {
|
||||
s, db, _, _ := creationTestService(t)
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
if _, err := s.UpdateUserAgentMemorySetting("user-a", AgentMemorySettingRequest{CompactInterval: "yearly"}); err == nil {
|
||||
t.Fatal("非法周期必须拒绝")
|
||||
}
|
||||
view, err := s.UpdateUserAgentMemorySetting("user-a", AgentMemorySettingRequest{
|
||||
CompactInterval: "weekly", Model: "chan::gpt-text", ChannelID: "chan", ChannelModelKey: "gpt-text",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.CompactInterval != model.AgentMemoryCompactIntervalWeekly || view.ChannelID != "chan" {
|
||||
t.Fatalf("应保存周期和模型:%+v", view)
|
||||
}
|
||||
again, err := s.UpdateUserAgentMemorySetting("user-a", AgentMemorySettingRequest{CompactInterval: "daily"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.CompactInterval != model.AgentMemoryCompactIntervalDaily || again.ChannelID != "chan" || again.Model != "chan::gpt-text" {
|
||||
t.Fatalf("只改周期时必须保留已选模型:%+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentMemoryCompactDueAndBusy(t *testing.T) {
|
||||
now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
|
||||
stale := now.Add(-cloudAgentMemoryCompactStaleAfter - time.Minute)
|
||||
recent := now.Add(-time.Minute)
|
||||
if agentMemoryCompactDue(model.AgentMemorySetting{CompactInterval: model.AgentMemoryCompactIntervalOff}, now) {
|
||||
t.Fatal("关闭定时后不应到期")
|
||||
}
|
||||
if !agentMemoryCompactDue(model.AgentMemorySetting{CompactInterval: model.AgentMemoryCompactIntervalDaily}, now) {
|
||||
t.Fatal("从未压缩过的 daily 应到期")
|
||||
}
|
||||
last := now.Add(-2 * time.Hour)
|
||||
if agentMemoryCompactDue(model.AgentMemorySetting{CompactInterval: model.AgentMemoryCompactIntervalDaily, LastCompactAt: &last}, now) {
|
||||
t.Fatal("未满一天不应到期")
|
||||
}
|
||||
last = now.Add(-25 * time.Hour)
|
||||
if !agentMemoryCompactDue(model.AgentMemorySetting{CompactInterval: model.AgentMemoryCompactIntervalDaily, LastCompactAt: &last}, now) {
|
||||
t.Fatal("超过一天应到期")
|
||||
}
|
||||
if !agentMemoryCompactBusy(model.AgentMemorySetting{LastStatus: model.AgentMemoryCompactStatusQueued, CompactStartedAt: &recent}, now) {
|
||||
t.Fatal("排队未超时应视为忙碌")
|
||||
}
|
||||
if agentMemoryCompactBusy(model.AgentMemorySetting{LastStatus: model.AgentMemoryCompactStatusQueued, CompactStartedAt: &stale}, now) {
|
||||
t.Fatal("超过 30 分钟的排队应允许重试")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentMemoryCompactPlanStripsFence(t *testing.T) {
|
||||
plan, err := parseAgentMemoryCompactPlan("好的\n```json\n{\"rewrites\":[],\"merges\":[]}\n```\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Rewrites) != 0 || len(plan.Merges) != 0 {
|
||||
t.Fatalf("空计划应可解析:%+v", plan)
|
||||
}
|
||||
if _, err := parseAgentMemoryCompactPlan("不是 JSON"); err == nil {
|
||||
t.Fatal("非 JSON 必须失败")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user