Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_media_test.go
ddCat 656f64a3ce fix(agent): 云端 Agent - 媒体失败可续跑并补齐快照哈希
生成任务失败不再把整轮判死,漏传画布快照由服务端补当前内容哈希,没有偏好层时不暴露空的 profile 读取。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 23:31:54 +08:00

825 lines
34 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"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
func agentMediaFixture(t *testing.T) (*Service, *gorm.DB, cloudAgentMediaArgs) {
t.Helper()
s, db, _, _ := creationTestService(t)
capability := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceVolcengineArkVideo), "seedance-test")
capability.Video.References.PromptMaxChars = 16000
for _, row := range []any{
&model.ChannelModel{ID: "video-cm", ChannelID: "channel", ModelKey: "seedance-test", DisplayName: "视频测试", Capability: "video", Protocol: model.ChannelInterfaceVolcengineArkVideo, CapabilityConfigJSON: mustEncodeModelCapabilityConfig(t, capability), BillingMode: "per_second", PriceConfigured: true, Enabled: true},
&model.ChannelModelPriceTier{ID: "video-tier", ChannelModelID: "video-cm", SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "per_second", UnitPriceMicrocredits: 1, PriceConfigured: true, Enabled: true},
&model.Resource{ID: "ref-one", UserID: "user", Kind: "image", Status: "ready", MimeType: "image/png", Width: 640, Height: 480, Size: 50},
&model.Resource{ID: "ref-two", UserID: "user", Kind: "image", Status: "ready", MimeType: "image/png", Width: 640, Height: 480, Size: 50},
&model.Resource{ID: "private", UserID: "other", Kind: "image", Status: "ready", MimeType: "image/png"},
} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
doc := map[string]any{"nodes": []map[string]any{
{"id": "shot-1", "type": "text", "title": "镜头1", "position": map[string]any{"x": 10, "y": 10}, "width": 300, "metadata": map[string]any{"content": strings.Repeat("镜头完整指令", 500)}},
{"id": "cat", "type": "image", "title": "叮当猫在飞", "position": map[string]any{"x": 10, "y": 300}, "width": 300, "metadata": map[string]any{"status": "success", "storageKey": "resource:ref-one", "content": "https://must-not-expose.invalid/one.png"}},
{"id": "hero", "type": "image", "title": "古风奥特曼", "position": map[string]any{"x": 400, "y": 300}, "width": 300, "metadata": map[string]any{"status": "success", "storageKey": "resource:ref-two"}},
}, "connections": []any{}}
raw, _ := json.Marshal(doc)
if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: string(raw)}).Error; err != nil {
t.Fatal(err)
}
audio := true
a := cloudAgentMediaArgs{Mode: "video", Prompt: "镜头1,参考图1是英雄,参考图2是叮当猫", ChannelID: "channel", ChannelModelKey: "seedance-test", Duration: 12, Size: "9:16", VideoGenerateAudio: &audio, SnapshotHash: creationHash(doc), NodeID: "video-shot-1", Title: "镜头1视频", SourceNodeID: "shot-1", ReferenceNodeIDs: []string{"hero", "cat"}}
return s, db, a
}
func agentMediaCall(a cloudAgentMediaArgs) cloudAgentCall {
raw, _ := json.Marshal(a)
call := cloudAgentCall{ID: "media-call"}
call.Function.Name, call.Function.Arguments = "generate_media", string(raw)
return call
}
func agentMediaRun(t *testing.T, s *Service, a cloudAgentMediaArgs, permission string, idempotencyKeys ...string) (*model.CloudAgentExecution, cloudAgentRuntime) {
t.Helper()
req := agentTestRequest()
if len(idempotencyKeys) > 0 {
req.IdempotencyKey = idempotencyKeys[0]
}
req.PermissionMode = permission
req.Budget.MaxGenerationTasks = 2
req.Budget.MaxVideoSeconds = 24
root, err := s.CreateCloudAgentRun("user", req, "")
if err != nil {
t.Fatal(err)
}
run, err := s.repo.CloudAgent("user", root.ID)
if err != nil {
t.Fatal(err)
}
state, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
state.ActiveTaskID = ""
state.Calls = []cloudAgentCall{agentMediaCall(a)}
if err = s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
return cloudAgentSave(current, &state)
}); err != nil {
t.Fatal(err)
}
run, _ = s.repo.CloudAgent("user", run.ID)
return run, state
}
func approveAgentMediaDraft(t *testing.T, s *Service, runID string) {
t.Helper()
run, err := s.CloudAgentRun("user", runID)
if err != nil || run.Approval == nil || run.Status != "waiting_approval" {
t.Fatalf("media must wait for approval even in auto mode: %v", err)
}
if err := s.DecideCloudAgentApproval("user", runID, run.Approval.ID, "approve", ""); err != nil {
t.Fatal(err)
}
if err := s.advanceCloudAgentByID("user", runID); err != nil {
t.Fatal(err)
}
}
func TestCloudAgentMediaApprovalCreatesNodeReferencesAndResult(t *testing.T) {
s, db, a := agentMediaFixture(t)
catalog, err := s.cloudAgentModelList(nil)
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(catalog)
if !strings.Contains(string(encoded), `"channelModelKey":"seedance-test"`) || strings.Contains(string(encoded), "logicalModelId") {
t.Fatalf("wrong catalog: %s", encoded)
}
run, state := agentMediaRun(t, s, a, "request_approval")
if err = s.advanceCloudAgentTool(run, &state); err != nil {
t.Fatal(err)
}
var count int64
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
if count != 0 {
t.Fatal("charged/submitted before approval")
}
waiting, err := s.CloudAgentRun("user", run.ID)
if err != nil || waiting.Approval == nil {
t.Fatalf("missing approval: %v", err)
}
if err = s.DecideCloudAgentApproval("user", run.ID, waiting.Approval.ID, "approve", ""); 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 state.MediaTaskID == "" {
t.Fatalf("no media task: %s", run.StateJSON)
}
task, err := s.repo.TaskForUser("user", state.MediaTaskID)
if err != nil {
t.Fatal(err)
}
var input canvasGenerationInput
if err = json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
t.Fatal(err)
}
if input.Config.Size != "9:16" || input.Config.VideoSeconds != "12" || input.Config.VideoGenerateAudio != "true" || len(input.ReferenceImages) != 2 || input.ReferenceImages[0].ID != "hero" || input.ReferenceImages[1].StorageKey != "resource:ref-one" || input.ReferenceImages[0].URL != "" {
t.Fatalf("lost generation contract: %+v", input)
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
nodes, _ := creationObjects(doc["nodes"])
node := nodes[a.NodeID]
if node == nil || node["type"] != "video" || node["metadata"].(map[string]any)["taskId"] != task.ID || len(creationMaps(doc["connections"])) != 3 {
t.Fatalf("no node/edges: %s", canvas.PayloadJSON)
}
if err = s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
if count != 1 {
t.Fatal("replay duplicated task")
}
if err = db.Create(&model.Resource{ID: "output", UserID: "user", Kind: "video", Status: "ready", MimeType: "video/mp4", Width: 720, Height: 1280}).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&model.Task{}).Where("id = ?", task.ID).Updates(map[string]any{"status": model.TaskStatusSucceeded, "result_json": `{"mode":"video","video":{"storageKey":"resource:output"}}`}).Error; err != nil {
t.Fatal(err)
}
if err = s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
canvas, _ = s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ = creationDocument(canvas.PayloadJSON)
nodes, _ = creationObjects(doc["nodes"])
meta := nodes[a.NodeID]["metadata"].(map[string]any)
if meta["status"] != "success" || meta["storageKey"] != "resource:output" {
t.Fatalf("result not persisted: %+v", meta)
}
run, _ = s.repo.CloudAgent("user", run.ID)
state, _ = cloudAgentDecode(run)
found := false
for _, event := range state.Events {
if event.Type == "canvas_updated" {
found = true
}
}
if !found || state.MediaTaskID != "" {
t.Fatal("completion event/checkpoint missing")
}
}
func TestCloudAgentMediaReferenceAndSnapshotGuards(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, state := agentMediaRun(t, s, a, "auto")
for _, test := range []struct {
name string
change func(*cloudAgentMediaArgs)
}{
{"stale snapshot", func(v *cloudAgentMediaArgs) { v.SnapshotHash = "old" }},
{"missing reference", func(v *cloudAgentMediaArgs) { v.ReferenceNodeIDs = []string{"missing"} }},
{"text as media", func(v *cloudAgentMediaArgs) { v.ReferenceNodeIDs = []string{"shot-1"} }},
{"duplicate reference", func(v *cloudAgentMediaArgs) { v.ReferenceNodeIDs = []string{"cat", "cat"} }},
{"existing node", func(v *cloudAgentMediaArgs) { v.NodeID = "cat" }},
{"mixed selection", func(v *cloudAgentMediaArgs) { v.LogicalModelID = "made-up" }},
{"duration budget", func(v *cloudAgentMediaArgs) { v.Duration = 25 }},
{"missing aspect ratio", func(v *cloudAgentMediaArgs) { v.Size = "" }},
} {
t.Run(test.name, func(t *testing.T) {
bad := a
test.change(&bad)
if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(bad)); err == nil {
t.Fatal("invalid request accepted")
}
})
}
if err := db.Model(&model.Resource{}).Where("id = ?", "ref-one").Update("status", "uploading").Error; err != nil {
t.Fatal(err)
}
if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(a)); err == nil {
t.Fatal("unready asset accepted")
}
if err := db.Model(&model.Resource{}).Where("id = ?", "ref-one").Update("user_id", "other").Error; err != nil {
t.Fatal(err)
}
if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(a)); err == nil {
t.Fatal("cross-user asset accepted")
}
}
func TestCloudAgentImageCreatesReferencedNode(t *testing.T) {
s, db, a := agentMediaFixture(t)
capability := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceGrokImage), "grok-image")
for _, row := range []any{
&model.ChannelModel{ID: "image-cm", ChannelID: "channel", ModelKey: "grok-image", Capability: "image", Protocol: model.ChannelInterfaceGrokImage, CapabilityConfigJSON: mustEncodeModelCapabilityConfig(t, capability), BillingMode: "fixed_request", PriceConfigured: true, Enabled: true},
&model.ChannelModelPriceTier{ID: "image-tier", ChannelModelID: "image-cm", SelectorKey: "{}", SelectorJSON: "{}", BillingMode: "fixed_request", UnitPriceMicrocredits: 1, PriceConfigured: true, Enabled: true},
} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
a.Mode, a.ChannelModelKey, a.Duration, a.VideoGenerateAudio = "image", "grok-image", 0, nil
a.Size, a.Quality, a.NodeID = "1:1", "2k", "image-shot-1"
a.ReferenceNodeIDs = []string{"cat"}
run, _ := agentMediaRun(t, s, a, "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 state.MediaTaskID == "" {
t.Fatalf("image admission failed: %+v", state.Events[len(state.Events)-1])
}
task, err := s.repo.TaskForUser("user", state.MediaTaskID)
if err != nil {
t.Fatal(err)
}
var input canvasGenerationInput
if err = json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
t.Fatal(err)
}
if task.Operation != "image_to_image" || len(input.ReferenceImages) != 1 || input.Config.Count != "1" || input.Config.Quality != "2k" {
t.Fatal("image generation contract was lost")
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
nodes, _ := creationObjects(doc["nodes"])
if nodes[a.NodeID]["type"] != "image" || len(creationMaps(doc["connections"])) != 2 {
t.Fatal("image node/references missing")
}
}
func TestCloudAgentCanvasReadsFullPromptAssetsAndConnections(t *testing.T) {
s, _, _ := agentMediaFixture(t)
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
result, err := cloudAgentCanvasState(s.repo, "user", doc, 0, []string{"shot-1", "cat"}, 0)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(result)
if strings.Contains(string(raw), "must-not-expose") || strings.Contains(string(raw), "storageKey") || strings.Contains(string(raw), "resource:ref-one") || !strings.Contains(string(raw), `"referenceReady":true`) || !strings.Contains(string(raw), strings.Repeat("镜头完整指令", 500)) {
t.Fatalf("incomplete/unsafe context: %.200s", raw)
}
}
func TestCloudAgentMediaRejectAndInvalidModelDoNotCreateTasks(t *testing.T) {
for _, scenario := range []string{"reject", "invalid-model", "stale-after-approval"} {
t.Run(scenario, func(t *testing.T) {
s, db, a := agentMediaFixture(t)
if scenario == "invalid-model" {
a.ChannelModelKey = "removed-model"
}
run, _ := agentMediaRun(t, s, a, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
run, _ = s.repo.CloudAgent("user", run.ID)
state, _ := cloudAgentDecode(run)
if scenario == "invalid-model" {
var count int64
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
if state.Approval != nil || count != 0 || state.CallIndex != 1 {
t.Fatal("invalid model created approval or task")
}
return
}
decision := "approve"
if scenario == "reject" {
decision = "reject"
}
if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, decision, ""); err != nil {
t.Fatal(err)
}
if scenario == "stale-after-approval" {
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", `{"nodes":[],"connections":[]}`).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)
var count int64
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
if scenario == "reject" {
if run.Status != "rejected" || state.Approval != nil || state.CallIndex != 0 {
t.Fatalf("rejection did not stop at the approval boundary: status=%s approval=%+v index=%d", run.Status, state.Approval, state.CallIndex)
}
for _, event := range state.Events {
if event.Type == "tool_failed" || event.Type == "run_failed" {
t.Fatalf("user rejection was reported as execution failure: %+v", event)
}
}
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
var modelSteps int64
db.Model(&model.Task{}).Where("operation = ?", "cloud_agent_step").Count(&modelSteps)
if modelSteps != 0 {
t.Fatalf("rejected approval triggered %d follow-up model tasks", modelSteps)
}
} else if state.CallIndex != 1 || run.Status == "failed" {
t.Fatalf("nonrecoverable approved-tool failure: index=%d status=%s", state.CallIndex, run.Status)
}
if count != 0 || state.Generations != 0 {
t.Fatalf("unsafe submission or nonrecoverable tool failure: count=%d index=%d status=%s", count, state.CallIndex, run.Status)
}
})
}
}
func TestCloudAgentMediaChangedCanvasRollsBackAdmission(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, state := agentMediaRun(t, s, a, "auto")
req, plan, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(a))
if err != nil {
t.Fatal(err)
}
ordersBefore, err := s.repo.BillingOrdersByTaskIDs("user", state.TaskIDs)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", `{"nodes":[],"connections":[]}`).Error; err != nil {
t.Fatal(err)
}
if err := s.enqueueCloudAgentTask(run, &state, req, plan); err != nil {
t.Fatal(err)
}
run, _ = s.repo.CloudAgent("user", run.ID)
state, _ = cloudAgentDecode(run)
ordersAfter, _ := s.repo.BillingOrdersByTaskIDs("user", state.TaskIDs)
var tasks, orders int64
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks)
db.Model(&model.BillingOrder{}).Count(&orders)
if tasks != 0 || orders != int64(len(ordersBefore)) || len(ordersAfter) != len(ordersBefore) || state.Generations != 0 || state.MediaTaskID != "" || state.CallIndex != 1 {
t.Fatal("failed canvas CAS left a media task, charge or consumed budget")
}
}
func TestCloudAgentMediaFillsMissingSnapshotHash(t *testing.T) {
s, db, a := agentMediaFixture(t)
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
want := a.SnapshotHash
a.SnapshotHash = ""
run, state := agentMediaRun(t, s, a, "auto", "fill-missing-snapshot")
_, plan, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(a))
if err != nil {
t.Fatalf("missing snapshotHash should be filled from current canvas: %v", err)
}
if plan.Args.SnapshotHash == "" {
t.Fatal("filled snapshotHash is empty")
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
if plan.Args.SnapshotHash != cloudAgentMediaContentHash(doc) {
t.Fatalf("filled hash %s, want media content hash", plan.Args.SnapshotHash)
}
if want == "" {
t.Fatal("fixture snapshot missing")
}
}
func TestCloudAgentMediaFailedTaskUpdatesNode(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, a, "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 state.MediaTaskID == "" {
t.Fatal("media task missing")
}
taskID := state.MediaTaskID
if err := db.Model(&model.Task{}).Where("id = ?", taskID).Updates(map[string]any{"status": model.TaskStatusFailed, "error": "上游拒绝该生成规格"}).Error; err != nil {
t.Fatal(err)
}
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
nodes, _ := creationObjects(doc["nodes"])
if nodes[a.NodeID]["metadata"].(map[string]any)["status"] != "error" {
t.Fatal("failed generation remained loading")
}
run, _ = s.repo.CloudAgent("user", run.ID)
state, _ = cloudAgentDecode(run)
last := state.Events[len(state.Events)-1]
result, _ := last.Payload["result"].(map[string]any)
if last.Type != "tool_failed" || result["taskId"] != taskID || result["nodeId"] != a.NodeID || !strings.Contains(stringValue(result["error"]), "上游拒绝该生成规格") {
t.Fatalf("failure lost diagnostic details: %+v", last)
}
if run.Status == "failed" {
t.Fatalf("media task failure must keep the agent run alive: status=%s events=%d", run.Status, len(state.Events))
}
}
func TestCloudAgentModelListFiltersActualReferences(t *testing.T) {
s, db, _ := agentMediaFixture(t)
config := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceVolcengineArkVideo), "text-only")
config.Video.Operations = []string{"text_to_video"}
config.Video.DefaultOperation = "text_to_video"
config.Video.References.MaxImages = 0
config.Video.References.MaxVideos = 0
config.Video.References.MaxAudios = 0
if err := db.Create(&model.ChannelModel{ID: "text-only", ChannelID: "channel", ModelKey: "text-only", Capability: "video", Protocol: model.ChannelInterfaceVolcengineArkVideo, CapabilityConfigJSON: mustEncodeModelCapabilityConfig(t, config), BillingMode: "per_second", PriceConfigured: true, Enabled: true}).Error; err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
args string
wantText bool
}{
{`{}`, true},
{`{"mode":"video","referenceNodeIds":[]}`, true},
{`{"mode":"video","referenceNodeIds":["hero","cat"]}`, false},
} {
intent, err := s.cloudAgentModelIntent("user", "agent-canvas", tc.args)
if err != nil {
t.Fatal(err)
}
catalog, err := s.cloudAgentModelList(intent)
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(catalog)
if strings.Contains(string(encoded), `"channelModelKey":"text-only"`) != tc.wantText || !strings.Contains(string(encoded), `"channelModelKey":"seedance-test"`) {
t.Fatalf("unexpected filtered catalog for %s: %s", tc.args, encoded)
}
if intent != nil && !tc.wantText && (intent.Inputs["image"] != 2 || intent.Operation != "image_to_video") {
t.Fatalf("intent = %+v", intent)
}
}
for _, args := range []string{`{"mode":"video","referenceNodeIds":["hero","hero"]}`, `{"mode":"video","referenceNodeIds":["missing"]}`, `{"referenceNodeIds":["hero"]}`, `{"mode":"audio","referenceNodeIds":["hero"]}`} {
if _, err := s.cloudAgentModelIntent("user", "agent-canvas", args); err == nil {
t.Fatalf("accepted %s", args)
}
}
if _, err := s.cloudAgentModelIntent("other", "agent-canvas", `{"mode":"video"}`); err == nil {
t.Fatal("cross-user canvas accepted")
}
if err := db.Model(&model.ChannelModel{}).Where("id = ?", "video-cm").Update("capability_config_json", mustEncodeModelCapabilityConfig(t, config)).Error; err != nil {
t.Fatal(err)
}
intent, err := s.cloudAgentModelIntent("user", "agent-canvas", `{"mode":"video","referenceNodeIds":["hero","cat"]}`)
if err != nil {
t.Fatal(err)
}
catalog, err := s.cloudAgentModelList(intent)
if err != nil {
t.Fatal(err)
}
if len(catalog.(map[string]any)["models"].([]map[string]any)) != 0 {
t.Fatal("no-match query fell back to incompatible models")
}
}
func TestCloudAgentMediaDraftReuseLifecycle(t *testing.T) {
for _, tc := range []struct {
name, status, ownerUser, ownerCanvas string
cleanup, submitted, result, locked, allowed bool
}{
{name: "completed", status: "completed", allowed: true},
{name: "cancelled", status: "cancelled", allowed: true},
{name: "failed", status: "failed", allowed: true},
{name: "approval", status: "waiting_approval"},
{name: "running", status: "running"},
{name: "cleanup", status: "cancelled", cleanup: true},
{name: "submitted", status: "completed", submitted: true},
{name: "result", status: "completed", result: true},
{name: "locked", status: "completed", locked: true},
{name: "other user", status: "completed", ownerUser: "other"},
{name: "other canvas", status: "completed", ownerCanvas: "other"},
{name: "plain placeholder", allowed: true},
} {
t.Run(tc.name, func(t *testing.T) {
s, db, a := agentMediaFixture(t)
meta := map[string]any{"status": "idle", "locked": tc.locked, "composerContent": "保留的草稿"}
if tc.status != "" {
owner := &model.CloudAgentExecution{ID: "old-run", UserID: firstNonEmpty(tc.ownerUser, "user"), CanvasID: firstNonEmpty(tc.ownerCanvas, "agent-canvas"), Status: tc.status, CleanupPending: tc.cleanup}
if err := db.Create(owner).Error; err != nil {
t.Fatal(err)
}
meta["agentDraftRunId"] = owner.ID
}
if tc.submitted {
meta["taskId"] = "existing-task"
}
if tc.result {
meta["storageKey"] = "resource:existing"
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
doc["nodes"] = append(creationMaps(doc["nodes"]), map[string]any{"id": a.NodeID, "type": "video", "metadata": meta})
raw, _ := json.Marshal(doc)
if err := db.Model(canvas).Update("payload_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
a.DraftRunID, a.SnapshotHash = "next-run", cloudAgentCanvasHash(doc)
_, _, _, err := cloudAgentMediaDocument(s.repo, "user", "agent-canvas", a)
if (err == nil) != tc.allowed {
t.Fatalf("allowed=%v error=%v", tc.allowed, err)
}
})
}
}
func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, state := agentMediaRun(t, s, a, "auto")
if err := s.advanceCloudAgentTool(run, &state); err != nil {
t.Fatal(err)
}
if err := s.CancelCloudAgent(context.Background(), "user", run.ID); err != nil {
t.Fatal(err)
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
view, err := cloudAgentCanvasState(s.repo, "user", doc, 0, []string{a.NodeID}, 0)
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(view)
if !strings.Contains(string(encoded), `"ownerStatus":"cancelled"`) || !strings.Contains(string(encoded), `"submitted":false`) {
t.Fatalf("missing draft lifecycle: %s", encoded)
}
a.SnapshotHash = cloudAgentCanvasHash(doc)
a.ReferenceNodeIDs = []string{"hero"}
next, nextState := agentMediaRun(t, s, a, "auto", "resume-draft-next-turn")
if next.ID == run.ID {
t.Fatal("expected a distinct run")
}
if err := s.advanceCloudAgentTool(next, &nextState); err != nil {
t.Fatal(err)
}
latest, err := s.CloudAgentRun("user", next.ID)
if err != nil || latest.Approval == nil {
t.Fatalf("new approval missing: %+v %v", latest, err)
}
canvas, _ = s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ = creationDocument(canvas.PayloadJSON)
if len(creationMaps(doc["nodes"])) != 4 {
t.Fatal("resuming duplicated the node")
}
edges := creationMaps(doc["connections"])
if len(edges) != 2 {
t.Fatalf("stale draft references retained: %+v", edges)
}
for _, edge := range edges {
if stringValue(edge["fromNodeId"]) == "cat" {
t.Fatal("removed reference still connected")
}
}
var count int64
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("resuming charged before new approval")
}
approveAgentMediaDraft(t, s, next.ID)
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("approved task count = %d", count)
}
}
func TestCloudAgentAutoMediaDraftRequiresExplicitApproval(t *testing.T) {
for _, decision := range []string{"approve", "reject", "cancel"} {
t.Run(decision, func(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, a, "auto")
var ordersBefore int64
db.Model(&model.BillingOrder{}).Count(&ordersBefore)
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 != "waiting_approval" || state.Approval == nil || state.Approval.ModelName != "视频测试" || state.MediaTaskID != "" {
t.Fatal("auto mode bypassed approval or omitted selected model")
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
nodes, _ := creationObjects(doc["nodes"])
meta, _ := nodes[a.NodeID]["metadata"].(map[string]any)
if meta["status"] != "idle" || meta["taskId"] != nil || meta["size"] != "9:16" || meta["agentDraftRunId"] != run.ID || len(creationMaps(doc["connections"])) != 3 {
t.Fatalf("missing uncharged draft with references: %+v", meta)
}
// Re-entering the tool while awaiting a decision must remain a no-op.
if err := s.advanceCloudAgentTool(run, &state); err != nil {
t.Fatal(err)
}
var count, orders int64
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
db.Model(&model.BillingOrder{}).Count(&orders)
if count != 0 || orders != ordersBefore {
t.Fatal("draft created a task or charge")
}
// Another run may not take over this draft, even with the latest snapshot.
foreign := a
foreign.DraftRunID, foreign.SnapshotHash = "another-run", cloudAgentCanvasHash(doc)
if _, _, _, err := cloudAgentMediaDocument(s.repo, "user", "agent-canvas", foreign); err == nil {
t.Fatal("foreign draft overwrite accepted")
}
if decision == "cancel" {
if err := s.CancelCloudAgent(context.Background(), "user", run.ID); err != nil {
t.Fatal(err)
}
} else {
// A local focus/zoom autosave cannot change the approved content.
doc["viewport"], doc["updatedAt"] = map[string]any{"x": -900, "y": -500, "k": 0.8}, "later"
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)
}
for range 2 {
if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, decision, ""); err != nil {
t.Fatal(err)
}
}
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
}
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
db.Model(&model.BillingOrder{}).Count(&orders)
if decision == "approve" {
if count != 1 {
t.Fatal("explicit approval did not create exactly one task")
}
} else if count != 0 || orders != ordersBefore {
t.Fatal("reject/cancel submitted generation")
}
})
}
}
func TestCloudAgentMediaPromptCountsUnicodeCharacters(t *testing.T) {
s, _, a := agentMediaFixture(t)
run, state := agentMediaRun(t, s, a, "auto")
a.Prompt = strings.Repeat("镜", 16000)
if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(a)); err != nil {
t.Fatal(err)
}
a.Prompt += "头"
if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(a)); err == nil || !strings.Contains(err.Error(), "16001") {
t.Fatal("missing exact character count")
}
}
func TestCloudAgentMediaCancellationUpdatesNode(t *testing.T) {
s, _, a := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, a, "auto")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
approveAgentMediaDraft(t, s, run.ID)
if err := s.CancelCloudAgent(context.Background(), "user", run.ID); err != nil {
t.Fatal(err)
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
nodes, _ := creationObjects(doc["nodes"])
if nodes[a.NodeID]["metadata"].(map[string]any)["status"] != "error" {
t.Fatal("cancelled generation remained loading")
}
run, _ = s.repo.CloudAgent("user", run.ID)
state, _ := cloudAgentDecode(run)
if run.Status != "cancelled" || state.MediaTaskID != "" {
t.Fatal("cancellation did not checkpoint media completion")
}
}
func TestCloudAgentStoryboardPrecisionAndPermission(t *testing.T) {
storyboard := map[string]any{"rows": []any{
map[string]any{"id": "shot-1", "shotNumber": float64(1), "videoMotionPrompt": strings.Repeat("切镜指令", 500), "assetBindings": []any{map[string]any{"nodeId": "hero", "role": "character", "url": "do-not-expose"}}, "private": "do-not-expose"},
map[string]any{"id": "shot-2", "videoMotionPrompt": "下一镜头"},
}}
result := cloudAgentStoryboardState(storyboard, 0, true)
raw, _ := json.Marshal(result)
if !strings.Contains(string(raw), strings.Repeat("切镜指令", 500)) || strings.Contains(string(raw), "do-not-expose") || result["nextOffset"] != 1 {
t.Fatal("storyboard context missing full prompt, pagination or privacy boundary")
}
next := cloudAgentStoryboardState(storyboard, 1, true)
if next["hasMore"] != false || next["rows"].([]any)[0].(map[string]any)["id"] != "shot-2" {
t.Fatal("storyboard pagination failed")
}
req := agentTestRequest()
req.PermissionMode, req.ContextScope, req.Budget.MaxGenerationTasks = "auto", nil, 1
if cloudAgentToolAllowed(req, "generate_media") || cloudAgentToolAllowed(req, "canvas_apply_ops") {
t.Fatal("canvas writes exposed without context permission")
}
}
func TestCloudAgentCanvasCreatesTypedNodesAndEdges(t *testing.T) {
s, _, a := agentMediaFixture(t)
policy, err := s.RuntimePolicy()
if err != nil {
t.Fatal(err)
}
call := cloudAgentCall{}
args := map[string]any{"snapshotHash": a.SnapshotHash, "ops": []map[string]any{
{"type": "add_node", "id": "blank-video", "nodeType": "video", "title": "镜头占位", "content": "生成提示词"},
{"type": "add_node", "id": "group", "nodeType": "frame", "title": "镜头组"},
{"type": "connect_nodes", "id": "ref-edge", "fromNodeId": "cat", "toNodeId": "blank-video"},
}}
raw, _ := json.Marshal(args)
call.Function.Arguments = string(raw)
if _, err = applyCloudAgentCanvas(s.repo, "user", "agent-canvas", call, policy); err != nil {
t.Fatal(err)
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
nodes, _ := creationObjects(doc["nodes"])
meta := nodes["blank-video"]["metadata"].(map[string]any)
if meta["content"] != "" || meta["prompt"] != "生成提示词" || meta["composerContent"] != "生成提示词" || len(creationMaps(doc["connections"])) != 1 {
t.Fatal("typed nodes/edges missing")
}
}
func TestCloudAgentCanvasUpdatesExistingVideoDraftThroughCapabilityContract(t *testing.T) {
s, db, _ := agentMediaFixture(t)
policy, err := s.RuntimePolicy()
if err != nil {
t.Fatal(err)
}
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)
}
nodes := creationMaps(doc["nodes"])
nodes = append(nodes, map[string]any{
"id": "video-1789310237935-mmh3-baby-fullmoon", "type": "video", "title": "旧视频草稿",
"position": map[string]any{"x": float64(200), "y": float64(80)}, "width": float64(720), "height": float64(405),
"metadata": map[string]any{
"content": "", "prompt": "原始已提交提示词", "composerContent": "原始草稿", "status": "error",
"referenceNodeIds": []any{"cat"}, "referenceIssue": "参考资产尚未准备完成",
},
})
doc["nodes"] = nodes
rawDocument, _ := json.Marshal(doc)
if err := db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvas.ID, "user").Update("payload_json", string(rawDocument)).Error; err != nil {
t.Fatal(err)
}
call := cloudAgentCall{ID: "update-video-draft"}
arguments, _ := json.Marshal(map[string]any{
"snapshotHash": cloudAgentCanvasHash(doc),
"ops": []map[string]any{{
"type": "update_node", "id": "video-1789310237935-mmh3-baby-fullmoon",
"patch": map[string]any{"title": "满月庆祝视频草稿(舒缓呼吸感)", "content": "下一版舒缓视频提示词"},
}},
})
call.Function.Arguments = string(arguments)
if _, err := applyCloudAgentCanvas(s.repo, "user", canvas.ID, call, policy); err != nil {
t.Fatal(err)
}
updatedCanvas, err := s.repo.CanvasProjectForUser("user", canvas.ID)
if err != nil {
t.Fatal(err)
}
updatedDocument, err := creationDocument(updatedCanvas.PayloadJSON)
if err != nil {
t.Fatal(err)
}
updatedNodes, err := creationObjects(updatedDocument["nodes"])
if err != nil {
t.Fatal(err)
}
updated := updatedNodes["video-1789310237935-mmh3-baby-fullmoon"]
metadata := updated["metadata"].(map[string]any)
if updated["title"] != "满月庆祝视频草稿(舒缓呼吸感)" || metadata["composerContent"] != "下一版舒缓视频提示词" {
t.Fatalf("video draft was not updated: %#v", updated)
}
if metadata["prompt"] != "原始已提交提示词" || metadata["status"] != "error" || metadata["referenceIssue"] != "参考资产尚未准备完成" {
t.Fatalf("video task state or submission snapshot was overwritten: %#v", metadata)
}
}