package app import ( "context" "encoding/json" "strings" "testing" "yingce/backend/internal/model" "yingce/backend/internal/prompts" ) func agentCanvasPatchForOperation(t *testing.T, state cloudAgentRuntime, operation string) map[string]any { t.Helper() var found map[string]any for _, event := range state.Events { if event.Type == "canvas_updated" && event.Payload["operation"] == operation { if found != nil { t.Fatalf("duplicate canvas event for %s", operation) } found = event.Payload } } if found == nil { t.Fatalf("missing persisted canvas event for %s", operation) } return found } func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *testing.T) { for _, status := range []model.TaskStatus{model.TaskStatusSucceeded, model.TaskStatusFailed, model.TaskStatusCancelled} { t.Run(string(status), func(t *testing.T) { s, db, args := agentMediaFixture(t) run, _ := agentMediaRun(t, s, args, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } approveAgentMediaDraft(t, s, run.ID) run, _ = s.repo.CloudAgent("user", run.ID) state, _ := cloudAgentDecode(run) taskID := state.MediaTaskID if taskID == "" { t.Fatal("approved task missing") } if status == model.TaskStatusCancelled { if err := s.CancelCloudAgent(context.Background(), "user", run.ID); err != nil { t.Fatal(err) } } else { result := "{}" if status == model.TaskStatusSucceeded { if err := db.Create(&model.Resource{ID: "patch-output", UserID: "user", Kind: "video", Status: "ready", MimeType: "video/mp4"}).Error; err != nil { t.Fatal(err) } result = `{"mode":"video","video":{"storageKey":"resource:patch-output"}}` } if err := db.Model(&model.Task{}).Where("id = ?", taskID).Updates(map[string]any{"status": status, "result_json": result, "error": "上游拒绝"}).Error; err != nil { t.Fatal(err) } if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } } run, _ = s.repo.CloudAgent("user", run.ID) state, _ = cloudAgentDecode(run) draft := agentCanvasPatchForOperation(t, state, "generate_media_draft") patch := draft["canvasPatch"].(map[string]any) changes := creationMaps(patch["nodes"]) if patch["canvasId"] != "agent-canvas" || len(changes) != 1 || changes[0]["before"] != nil || len(creationMaps(patch["connections"])) != 3 { t.Fatalf("draft is not a bounded node/edge delta: %+v", patch) } referenceTitles := map[string]bool{} for _, action := range creationMaps(draft["actions"]) { if action["action"] == "referenced" { referenceTitles[stringValue(action["title"])] = true } } if !referenceTitles["叮当猫在飞"] || !referenceTitles["古风奥特曼"] { t.Fatalf("persisted trace lost referenced node titles: %+v", draft["actions"]) } submission := agentCanvasPatchForOperation(t, state, "generate_media_submit")["canvasPatch"].(map[string]any) submitted := creationMaps(submission["nodes"])[0]["after"].(map[string]any)["metadata"].(map[string]any) if submitted["status"] != "loading" || submitted["taskId"] != taskID || len(creationMaps(submission["connections"])) != 0 { t.Fatalf("submission failed to lock the existing draft: %+v", submission) } completion := agentCanvasPatchForOperation(t, state, "generate_media_complete")["canvasPatch"].(map[string]any) previousRevision := patch["baseRevision"] for _, delta := range []map[string]any{patch, submission, completion} { if delta["baseRevision"] != previousRevision { t.Fatalf("discontinuous Agent revision: %+v", delta) } base, ok := delta["baseRevision"].(float64) next, nextOK := delta["revision"].(float64) if !ok || !nextOK || next != base+1 { t.Fatalf("invalid Agent revisions: %+v", delta) } previousRevision = delta["revision"] } canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas") if err != nil || float64(canvas.Revision) != previousRevision { t.Fatalf("Agent save did not retain current revision: %+v %v", canvas, err) } changes = creationMaps(completion["nodes"]) if len(changes) != 1 || len(creationMaps(completion["connections"])) != 0 { t.Fatalf("completion includes unrelated canvas data: %+v", completion) } before := changes[0]["before"].(map[string]any)["metadata"].(map[string]any) after := changes[0]["after"].(map[string]any)["metadata"].(map[string]any) want := "error" if status == model.TaskStatusSucceeded { want = "success" } if before["status"] != "loading" || after["status"] != want || after["taskStatus"] != string(status) || after["taskId"] != taskID { t.Fatalf("terminal patch lost task identity/state: %+v", changes[0]) } // Replaying a checkpoint must not create another charged task or delta. if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } var count int64 db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count) if count != 1 { t.Fatal("terminal handling resubmitted a charged task") } }) } } func TestCloudAgentPatchCoversProjectedRowMetadata(t *testing.T) { before := map[string]any{"nodes": []any{}, "connections": []any{}} after := map[string]any{"id": "canvas", "revision": float64(2), "createdAt": "2026-10-11T00:00:00Z", "updatedAt": "2026-10-11T01:00:00Z", "title": "", "projectId": "", "nodes": []any{}, "connections": []any{}} if !cloudAgentPatchCoversDocument(before, after) { t.Fatal("database row metadata and empty projected fields should not force a refresh") } after["title"] = "新标题" if cloudAgentPatchCoversDocument(before, after) { t.Fatal("a changed canvas title must still force a refresh") } } func TestCloudAgentMediaImageSourceHasActionableCorrection(t *testing.T) { s, _, args := agentMediaFixture(t) run, state := agentMediaRun(t, s, args, "request_approval") args.SourceNodeID = "cat" if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(args)); err == nil || !strings.Contains(err.Error(), "referenceNodeIds") { t.Fatalf("image source must point to the correct field: %v", err) } args.ReferenceNodeIDs = []string{"hero"} if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(args)); err == nil || !strings.Contains(err.Error(), "sourceNodeId 仅接受可作为文本输入") { t.Fatalf("nonduplicate image source must also be rejected: %v", err) } args.SourceNodeID = "" if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(args)); err != nil { t.Fatalf("reference-only image-to-video should be valid: %v", err) } } func TestCloudAgentDeletionInvalidatesTheWholeCanvasRevision(t *testing.T) { s, _, _ := agentMediaFixture(t) canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas") if err != nil { t.Fatal(err) } previous := canvas.PayloadJSON doc, err := creationDocument(previous) if err != nil { t.Fatal(err) } nodes := creationMaps(doc["nodes"]) doc["nodes"] = nodes[1:] nodes[1]["title"] = "Also updated" raw, _ := json.Marshal(doc) canvas.PayloadJSON = string(raw) if err := saveCreationCanvasWithHistory(s.repo, canvas, previous); err != nil { t.Fatal(err) } state := cloudAgentRuntime{} if err := emitCloudAgentCanvasChange(s.repo, "run", &state, cloudAgentMutationInput{UserID: "user", CanvasID: canvas.ID, BeforeJSON: previous}); err != nil { t.Fatal(err) } if len(state.Events) != 1 || state.Events[0].Payload["requiresRefresh"] != true || state.Events[0].Payload["canvasPatch"] != nil { t.Fatalf("incomplete delta would acknowledge a deleted node: %+v", state.Events) } } func TestCloudAgentCanvasOperationTraceSharesToolCallID(t *testing.T) { s, _, args := agentMediaFixture(t) run, state := agentMediaRun(t, s, args, "auto") call := cloudAgentCall{ID: "canvas-call"} call.Function.Name = "canvas_apply_ops" raw, _ := json.Marshal(map[string]any{"snapshotHash": args.SnapshotHash, "ops": []map[string]any{ {"type": "update_node", "id": "cat", "patch": map[string]any{"title": "叮当猫飞行参考", "content": "下一版参考图提示词"}}, {"type": "add_node", "id": "trace-video", "nodeType": "video", "title": "视频草稿"}, {"type": "connect_nodes", "id": "trace-reference", "fromNodeId": "cat", "toNodeId": "trace-video"}, }}) call.Function.Arguments = string(raw) state.Calls = []cloudAgentCall{call} if err := s.advanceCloudAgentTool(run, &state); err != nil { t.Fatal(err) } run, _ = s.repo.CloudAgent("user", run.ID) state, _ = cloudAgentDecode(run) trace := agentCanvasPatchForOperation(t, state, "canvas_apply_ops") last := state.Events[len(state.Events)-1] if trace["callId"] != call.ID || last.Type != "tool_completed" || last.Payload["callId"] != call.ID { t.Fatalf("canvas delta and tool result cannot be deduplicated: %+v / %+v", trace, last) } result := last.Payload["result"].(map[string]any) if result["committed"] != true { t.Fatalf("canvas_apply_ops result did not acknowledge persistence: %+v", result) } preview := result["preview"].(map[string]any) if preview["status"] != "applied" || strings.Contains(stringValue(preview["description"]), "批准后才会写入") { t.Fatalf("canvas_apply_ops result still looks like an approval preview: %+v", preview) } canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas") if err != nil { t.Fatal(err) } doc, err := creationDocument(canvas.PayloadJSON) if err != nil { t.Fatal(err) } var cat map[string]any for _, node := range creationMaps(doc["nodes"]) { if stringValue(node["id"]) == "cat" { cat = node break } } if cat == nil || stringValue(cat["title"]) != "叮当猫飞行参考" { t.Fatalf("canvas_apply_ops did not persist the requested prompt contract: %+v", cat) } metadata := cat["metadata"].(map[string]any) generationSpec := metadata["generationSpec"].(map[string]any) if stringValue(generationSpec["prompt"]) != "下一版参考图提示词" { t.Fatalf("canvas_apply_ops did not persist the requested prompt contract: %+v", cat) } actions := creationMaps(trace["actions"]) byActionAndNode := map[string]map[string]any{} for _, action := range actions { byActionAndNode[stringValue(action["action"])+":"+stringValue(action["nodeId"])] = action } updated := byActionAndNode["updated:cat"] if updated == nil || updated["title"] != "叮当猫在飞" || updated["resultTitle"] != "叮当猫飞行参考" { t.Fatalf("canvas operation trace lost updated node identity: %+v", actions) } fieldValues := []string{} switch fields := updated["fields"].(type) { case []string: fieldValues = fields case []any: for _, field := range fields { fieldValues = append(fieldValues, stringValue(field)) } } if len(fieldValues) != 2 || fieldValues[0] != "节点名称" || fieldValues[1] != "提示词" { t.Fatalf("canvas operation trace lost updated fields: %+v", updated) } created := byActionAndNode["created:trace-video"] if created == nil || created["title"] != "视频草稿" || created["nodeType"] != "video" { t.Fatalf("canvas operation trace lost created node identity: %+v", actions) } referenced := byActionAndNode["referenced:cat"] if referenced == nil || referenced["title"] != "叮当猫飞行参考" || referenced["targetNodeId"] != "trace-video" || referenced["targetTitle"] != "视频草稿" || referenced["targetNodeType"] != "video" { t.Fatalf("canvas operation trace lost reference endpoints: %+v", actions) } } func TestCloudAgentMissingCanvasStillCheckpointsMediaFailure(t *testing.T) { s, db, args := agentMediaFixture(t) run, _ := agentMediaRun(t, s, args, "request_approval") if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } approveAgentMediaDraft(t, s, run.ID) run, _ = s.repo.CloudAgent("user", run.ID) state, _ := cloudAgentDecode(run) if err := db.Model(&model.Task{}).Where("id = ?", state.MediaTaskID).Update("status", model.TaskStatusFailed).Error; err != nil { t.Fatal(err) } if err := db.Where("id = ?", "agent-canvas").Delete(&model.CanvasProject{}).Error; err != nil { t.Fatal(err) } if err := s.advanceCloudAgentByID("user", run.ID); err != nil { t.Fatal(err) } run, _ = s.repo.CloudAgent("user", run.ID) state, _ = cloudAgentDecode(run) if run.Status != "failed" || state.MediaTaskID != "" || state.Events[len(state.Events)-1].Type != "tool_failed" { t.Fatal("deleted canvas left the media checkpoint running") } for _, event := range state.Events { if event.Type == "canvas_updated" && event.Payload["operation"] == "generate_media_complete" { t.Fatal("deleted canvas produced a phantom node update") } } } func TestCloudAgentMediaPolicyAllowsDefaultsWithoutBypassingApproval(t *testing.T) { _, media, err := prompts.LoadAgentPolicies() if err != nil { t.Fatal(err) } for _, text := range []string{"安全默认", "不要求四项逐个确认", "一次性询问", "界面独立审批", "失败不得自动重提"} { if !strings.Contains(media.Text, text) { t.Fatalf("media policy lost %q", text) } } // Deltas remain JSON serializable when a newly created node has no before value. raw, err := json.Marshal(cloudAgentObjectChanges([]any{}, []map[string]any{{"id": "new"}})) if err != nil || !strings.Contains(string(raw), `"before":null`) { t.Fatalf("invalid creation delta: %s %v", raw, err) } }