Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_canvas_events_test.go

229 lines
9.7 KiB
Go

package app
import (
"context"
"encoding/json"
"strings"
"testing"
"infinite-canvas/backend/internal/model"
"infinite-canvas/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, "auto")
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)
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 TestCloudAgentMediaImageSourceHasActionableCorrection(t *testing.T) {
s, _, args := agentMediaFixture(t)
run, state := agentMediaRun(t, s, args, "auto")
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 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)
}
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, "auto")
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)
}
}