216 lines
9.9 KiB
Go
216 lines
9.9 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"testing"
|
||
|
||
"yingce/backend/internal/model"
|
||
)
|
||
|
||
// 回归(2026-09-15):模型一轮里并发提交多个写操作(实测:给 6 个镜头建图时一轮 3 个
|
||
// generate_media),它们共享同一个「读画布时的哈希」。第一个写成功后画布就变了,后续调用
|
||
// 全部撞快照 CAS,模型得靠报错里的新哈希重试 —— 而下一轮又并发提交,十几轮都跑不完。
|
||
//
|
||
// 这里锁住放宽的**边界**:只把「与本轮基线同源」的后续调用接到当前哈希;
|
||
// 本轮第一个调用、模型自己已改用新哈希的调用、以及跨轮的情况都不动。
|
||
func TestCloudAgentRefreshStepSnapshotHash(t *testing.T) {
|
||
s, db, a := agentMediaFixture(t)
|
||
// Windows:池里的连接要在 t.TempDir 清理前关掉,否则整个包报红。
|
||
if sqlDB, err := db.DB(); err == nil {
|
||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||
}
|
||
base := a.SnapshotHash
|
||
if base == "" {
|
||
t.Fatal("fixture 缺少 snapshotHash")
|
||
}
|
||
|
||
state := cloudAgentRuntime{Request: agentTestRequest(), Step: 3, CallIndex: 1}
|
||
state.Request.CanvasID = "agent-canvas"
|
||
state.Calls = []cloudAgentCall{agentMediaCall(a), agentMediaCall(a)}
|
||
// 生产路径在收到这一轮调用时就把「读画布时的哈希」记进快照(见 state.Calls = calls 处)。
|
||
state.StepSnapshotHash = base
|
||
|
||
run := &model.CloudAgentExecution{ID: "run-1", UserID: "user", CanvasID: "agent-canvas"}
|
||
|
||
// ① 画布没变 → 原样返回,不做任何改动。
|
||
got := s.cloudAgentRefreshStepSnapshotHash(run, &state, state.Calls[1])
|
||
if got.Function.Arguments != state.Calls[1].Function.Arguments {
|
||
t.Fatal("画布未变时不应改写参数")
|
||
}
|
||
|
||
// ② 画布变了(模拟本轮第一个写操作已成功)→ 第二个调用应接到最新哈希。
|
||
var canvas model.CanvasProject
|
||
if err := db.First(&canvas, "id = ?", "agent-canvas").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
beforeRaw := canvas.PayloadJSON
|
||
doc, err := creationDocument(canvas.PayloadJSON)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
doc["nodes"] = append(creationMaps(doc["nodes"]), map[string]any{"id": "sibling-write", "type": "image", "title": "本轮第一个写操作留下的节点"})
|
||
raw, _ := json.Marshal(doc)
|
||
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", string(raw)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
latest := cloudAgentCanvasHash(doc)
|
||
if latest == base {
|
||
t.Fatal("测试前提不成立:画布哈希没变")
|
||
}
|
||
if err := s.repo.CreateCloudAgentCanvasMutation(&model.CloudAgentCanvasMutation{ID: "mutation-1", RunID: run.ID, UserID: run.UserID, CanvasID: run.CanvasID, StepID: state.Calls[0].ID, Operation: "canvas_apply_ops", BeforeSnapshotHash: base, AfterSnapshotHash: latest, BeforeJSON: beforeRaw, Status: "applied"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
got = s.cloudAgentRefreshStepSnapshotHash(run, &state, state.Calls[1])
|
||
var args struct {
|
||
SnapshotHash string `json:"snapshotHash"`
|
||
NodeID string `json:"nodeId"`
|
||
}
|
||
if err := json.Unmarshal([]byte(got.Function.Arguments), &args); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if args.SnapshotHash != latest {
|
||
t.Fatalf("同源后续调用应接到最新哈希:got %s want %s", truncateRunes(args.SnapshotHash, 12), truncateRunes(latest, 12))
|
||
}
|
||
if args.NodeID != a.NodeID {
|
||
t.Fatal("刷新哈希时不能改动其它参数")
|
||
}
|
||
|
||
// ③ 本轮第一个调用不刷新(它的哈希本身就该被校验)。
|
||
// 真实运行时 call 恒为 Calls[CallIndex],所以这里把游标回到 0 才是等价场景。
|
||
state.CallIndex = 0
|
||
first := s.cloudAgentRefreshStepSnapshotHash(run, &state, state.Calls[0])
|
||
state.CallIndex = 1
|
||
if first.Function.Arguments != state.Calls[0].Function.Arguments {
|
||
t.Fatal("同一轮的第一个调用不应被改写")
|
||
}
|
||
|
||
// ④ 模型已自行改用新哈希的调用不刷新 —— 那种情况要如实报冲突,别掩盖。
|
||
// 基线与该调用不同源(模型中途重读过画布),就不该替它抹平。
|
||
own := state.Calls[1]
|
||
var ownArgs map[string]any
|
||
if err := json.Unmarshal([]byte(own.Function.Arguments), &ownArgs); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
ownArgs["snapshotHash"] = "model-reread-hash"
|
||
rawOwn, _ := json.Marshal(ownArgs)
|
||
own.Function.Arguments = string(rawOwn)
|
||
again := s.cloudAgentRefreshStepSnapshotHash(run, &state, own)
|
||
if again.Function.Arguments != own.Function.Arguments {
|
||
t.Fatal("与基线不同源的调用不应被改写")
|
||
}
|
||
|
||
// ⑤ 批卡批准后的真实序列:服务端把整批调用统一改写成同一个新哈希,基线一起抬到该哈希;
|
||
// 此后逐条提交,**每提交一条画布都会再变**(那条草稿节点要绑上 taskId)。
|
||
// 后面几条必须仍能被补到当时的哈希 —— 补不上就撞快照 CAS,
|
||
// 实测表现就是「一轮里第二个镜头必被拒」。基线也不能再被 Calls[0] 的改写带跑,
|
||
// 否则这几条会被判成"模型自己换了哈希"而跳过补哈希。
|
||
state.Calls[1].Function.Arguments = got.Function.Arguments // 服务端:整批同一个哈希
|
||
state.StepSnapshotHash = latest // 服务端:基线同步抬到该哈希
|
||
doc["nodes"] = append(creationMaps(doc["nodes"]), map[string]any{"id": "bound-task", "type": "video", "title": "第一条提交后绑上 taskId"})
|
||
rawBound, _ := json.Marshal(doc)
|
||
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", string(rawBound)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
afterBind := cloudAgentCanvasHash(doc)
|
||
if afterBind == latest {
|
||
t.Fatal("测试前提不成立:提交第一条后画布哈希没变")
|
||
}
|
||
if err := s.repo.CreateCloudAgentCanvasMutation(&model.CloudAgentCanvasMutation{ID: "mutation-2", RunID: run.ID, UserID: run.UserID, CanvasID: run.CanvasID, StepID: state.Calls[1].ID, Operation: "generate_media_submit", BeforeSnapshotHash: latest, AfterSnapshotHash: afterBind, BeforeJSON: string(raw), Status: "applied"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
fresh := s.cloudAgentRefreshStepSnapshotHash(run, &state, state.Calls[1])
|
||
var freshArgs struct {
|
||
SnapshotHash string `json:"snapshotHash"`
|
||
}
|
||
if err := json.Unmarshal([]byte(fresh.Function.Arguments), &freshArgs); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if freshArgs.SnapshotHash != afterBind {
|
||
t.Fatalf("同源调用应被补到最新哈希:got %s want %s", truncateRunes(freshArgs.SnapshotHash, 12), truncateRunes(afterBind, 12))
|
||
}
|
||
|
||
empty := agentMediaCall(a)
|
||
var emptyArgs map[string]any
|
||
if err := json.Unmarshal([]byte(empty.Function.Arguments), &emptyArgs); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
delete(emptyArgs, "snapshotHash")
|
||
rawEmpty, _ := json.Marshal(emptyArgs)
|
||
empty.Function.Arguments = string(rawEmpty)
|
||
filled := s.cloudAgentRefreshStepSnapshotHash(run, &state, empty)
|
||
var filledArgs struct {
|
||
SnapshotHash string `json:"snapshotHash"`
|
||
}
|
||
if err := json.Unmarshal([]byte(filled.Function.Arguments), &filledArgs); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.First(&canvas, "id = ?", "agent-canvas").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
currentDoc, err := creationDocument(canvas.PayloadJSON)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if filledArgs.SnapshotHash != cloudAgentMediaContentHash(currentDoc) {
|
||
t.Fatalf("漏传 snapshotHash 应补当前媒体快照:got %s", truncateRunes(filledArgs.SnapshotHash, 12))
|
||
}
|
||
}
|
||
|
||
func TestCloudAgentRefreshStepSnapshotHashRejectsExternalWrites(t *testing.T) {
|
||
s, db, a := agentMediaFixture(t)
|
||
if sqlDB, err := db.DB(); err == nil {
|
||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||
}
|
||
baseCanvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
baseDoc, err := creationDocument(baseCanvas.PayloadJSON)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
base := cloudAgentCanvasHash(baseDoc)
|
||
state := cloudAgentRuntime{Request: agentTestRequest(), Step: 1, CallIndex: 1, StepSnapshotHash: base}
|
||
state.Request.CanvasID = "agent-canvas"
|
||
state.Calls = []cloudAgentCall{agentMediaCall(a), agentMediaCall(a)}
|
||
run := &model.CloudAgentExecution{ID: "run-current", UserID: "user", CanvasID: "agent-canvas"}
|
||
|
||
// A direct autosave or another tab has no current-run mutation record.
|
||
directDoc := cloneCreationDocument(baseDoc)
|
||
directDoc["nodes"] = append(creationMaps(directDoc["nodes"]), map[string]any{"id": "external-node", "type": "image"})
|
||
directRaw, _ := json.Marshal(directDoc)
|
||
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", string(directRaw)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := s.cloudAgentRefreshStepSnapshotHash(run, &state, state.Calls[1])
|
||
if got.Function.Arguments != state.Calls[1].Function.Arguments {
|
||
t.Fatal("无记录的外部写入不能被吸收")
|
||
}
|
||
|
||
// A different Agent run's mutation is also a conflict for this run.
|
||
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", baseCanvas.PayloadJSON).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
otherDoc := cloneCreationDocument(baseDoc)
|
||
otherDoc["nodes"] = append(creationMaps(otherDoc["nodes"]), map[string]any{"id": "other-run-node", "type": "image"})
|
||
otherRaw, _ := json.Marshal(otherDoc)
|
||
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", string(otherRaw)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
otherHash := cloudAgentCanvasHash(otherDoc)
|
||
if err := s.repo.CreateCloudAgentCanvasMutation(&model.CloudAgentCanvasMutation{ID: "mutation-other", RunID: "run-other", UserID: "user", CanvasID: "agent-canvas", StepID: "other-call", Operation: "canvas_apply_ops", BeforeSnapshotHash: base, AfterSnapshotHash: otherHash, Status: "applied"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got = s.cloudAgentRefreshStepSnapshotHash(run, &state, state.Calls[1])
|
||
if got.Function.Arguments != state.Calls[1].Function.Arguments {
|
||
t.Fatal("其他 Agent run 的写入不能被当前 run 吸收")
|
||
}
|
||
}
|
||
|
||
func cloneCreationDocument(doc map[string]any) map[string]any {
|
||
raw, _ := json.Marshal(doc)
|
||
copy, _ := creationDocument(string(raw))
|
||
return copy
|
||
}
|