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

211 lines
8.1 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 (
"context"
"encoding/json"
"strings"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestCloudAgentMediaCleanupUsesPersistedTaskTarget(t *testing.T) {
for _, missingTarget := range []bool{false, true} {
t.Run(map[bool]string{false: "known target", true: "unknown target"}[missingTarget], 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, state := agentInterjectionState(t, s, run.ID)
updates := map[string]any{"status": model.TaskStatusFailed, "error": "上游超时"}
if missingTarget {
updates["input_json"] = `{}`
}
if err := db.Model(&model.Task{}).Where("id = ?", state.MediaTaskID).Updates(updates).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.CloudAgentExecution{}).Where("id = ?", run.ID).Updates(map[string]any{"status": "failed", "cleanup_pending": true, "state_json": `{`}).Error; err != nil {
t.Fatal(err)
}
run, err := s.repo.CloudAgent("user", run.ID)
if err != nil {
t.Fatal(err)
}
if err := s.finishCloudAgentCleanup(context.Background(), run); err != nil {
t.Fatal(err)
}
run, err = s.repo.CloudAgent("user", run.ID)
if err != nil {
t.Fatal(err)
}
if run.CleanupPending || run.MediaTaskID != "" {
t.Fatal("cleanup checkpoint did not finish")
}
canvas, err := s.repo.CanvasProjectForUser("user", state.Request.CanvasID)
if err != nil {
t.Fatal(err)
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
t.Fatal(err)
}
nodes, _ := creationObjects(doc["nodes"])
meta := nodes[args.NodeID]["metadata"].(map[string]any)
if missingTarget {
if meta["status"] != "loading" || !strings.Contains(run.FailureMessage, "缺少有效的目标节点") {
t.Fatal("unknown target must not be guessed from a copied task ID")
}
} else if meta["status"] != "error" {
t.Fatal("known target did not receive terminal status")
}
})
}
}
func TestCloudAgentMediaCompletionKeepsGenerationAndWritebackFailures(t *testing.T) {
for _, test := range []struct {
name string
status model.TaskStatus
err string
change string
reason string
}{
{"failed task and deleted node", model.TaskStatusFailed, "上游拒绝该生成规格", "delete", "target_node_missing"},
{"failed task and replaced binding", model.TaskStatusFailed, "上游请求超时", "rebind", "task_binding_changed"},
{"successful task and deleted node", model.TaskStatusSucceeded, "", "delete", "target_node_missing"},
{"successful task without resource", model.TaskStatusSucceeded, "", "", "result_resource_unavailable"},
{"long task error remains readable", model.TaskStatusFailed, strings.Repeat("上游拒绝该生成规格", 40), "delete", "target_node_missing"},
{"private task error remains redacted", model.TaskStatusFailed, "upstream https://private.invalid/?token=private-value", "delete", "target_node_missing"},
} {
t.Run(test.name, 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, state := agentInterjectionState(t, s, run.ID)
taskID := state.MediaTaskID
if taskID == "" {
t.Fatal("media task missing")
}
if err := db.Model(&model.Task{}).Where("id = ?", taskID).Updates(map[string]any{"status": test.status, "error": test.err, "result_json": `{}`}).Error; err != nil {
t.Fatal(err)
}
canvas, err := s.repo.CanvasProjectForUser("user", state.Request.CanvasID)
if err != nil {
t.Fatal(err)
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
t.Fatal(err)
}
nodes := creationMaps(doc["nodes"])
updated := make([]map[string]any, 0, len(nodes))
for _, node := range nodes {
if stringValue(node["id"]) == args.NodeID {
if test.change == "delete" {
continue
}
if test.change == "rebind" {
node["metadata"].(map[string]any)["taskId"] = "replacement-task"
}
}
updated = append(updated, node)
}
// A copied task ID on another node must not redirect the writeback.
if test.change == "rebind" {
updated = append(updated, map[string]any{"id": "different-node", "type": "video", "metadata": map[string]any{"taskId": taskID, "content": "keep-existing", "status": "success"}})
}
doc["nodes"] = updated
before, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&model.CanvasProject{}).Where("id = ?", canvas.ID).Update("payload_json", string(before)).Error; err != nil {
t.Fatal(err)
}
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
run, state = agentInterjectionState(t, s, run.ID)
last := state.Events[len(state.Events)-1]
result, _ := last.Payload["result"].(map[string]any)
if last.Type != "tool_failed" || result["status"] != string(test.status) || result["writebackReason"] != test.reason || result["targetNodeId"] != args.NodeID || result["taskId"] != taskID || result["taskSubmitted"] != true {
t.Fatalf("completion lost distinct outcome or target: %+v", result)
}
message := stringValue(result["error"])
if run.Status != "failed" || run.FailureMessage != message || message == "工具执行失败,请检查输入或稍后重试" || state.MediaTaskID != "" {
t.Fatalf("terminal failure was not checkpointed with its diagnostic: status=%s message=%q", run.Status, run.FailureMessage)
}
if test.status == model.TaskStatusFailed {
if stringValue(result["generationError"]) == "" || !strings.Contains(message, stringValue(result["generationError"])) || strings.Contains(message, "任务已成功") {
t.Fatal("writeback masked generation failure")
}
} else if result["generationError"] != nil || !strings.Contains(message, "媒体任务已成功") {
t.Fatal("successful generation reported as failed")
}
serialized, _ := json.Marshal(last.Payload)
if strings.Contains(string(serialized), "private-value") || strings.Contains(string(serialized), "private.invalid") {
t.Fatal("provider credentials leaked into Agent events")
}
failure := state.Events[len(state.Events)-2]
if failure.Type != "run_failed" || failure.Payload["reason"] != test.reason || failure.Payload["generationStatus"] != string(test.status) {
t.Fatal("run failure lost structured causes")
}
if test.change != "" {
after, err := s.repo.CanvasProjectForUser("user", canvas.ID)
if err != nil {
t.Fatal(err)
}
if after.PayloadJSON != string(before) {
t.Fatal("missing or rebound target modified another node")
}
}
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("completion failure resubmitted billable media")
}
})
}
}
func TestCloudAgentCapabilityGuidePublishesConnectionContracts(t *testing.T) {
guide := cloudAgentCapabilityGuide()
for _, nodeType := range []string{"script", "markdown", "text", "image", "video"} {
descriptor, ok := cloudAgentNodeCapabilityForType(nodeType)
if !ok {
t.Fatalf("missing capability %s", nodeType)
}
found := false
for _, line := range strings.Split(guide, "\n") {
if !strings.Contains(line, "("+nodeType+"):") {
continue
}
_, raw, ok := strings.Cut(line, " 连线能力:")
if !ok {
t.Fatalf("connection contract missing for %s", nodeType)
}
var contract map[string]any
if err := json.Unmarshal([]byte(raw), &contract); err != nil {
t.Fatal(err)
}
if contract["canSource"] != descriptor.Connection.CanSource || contract["canTarget"] != descriptor.Connection.CanTarget || contract["inputKind"] != descriptor.InputKind {
t.Fatalf("guide differs from execution registry for %s", nodeType)
}
found = true
}
if !found {
t.Fatalf("node %s missing from guide", nodeType)
}
}
}