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

1023 lines
41 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"
"errors"
"strings"
"testing"
"gorm.io/gorm"
"yingce/backend/internal/model"
"yingce/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 {
t.Fatal(err)
}
if run.Status != "waiting_approval" || run.Approval == nil {
stored, loadErr := s.repo.CloudAgent("user", runID)
state, decodeErr := cloudAgentDecode(stored)
if loadErr != nil {
decodeErr = loadErr
}
if decodeErr != nil || state.MediaTaskID == "" {
t.Fatalf("media generation was neither approved nor auto-submitted: status=%s task=%s err=%v", run.Status, state.MediaTaskID, decodeErr)
}
return
}
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 }},
} {
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 TestCloudAgentMediaArgumentFailureContinuesRun(t *testing.T) {
s, db, args := agentMediaFixture(t)
args.SourceNodeID = "cat" // Existing media node, invalid as a text source.
args.ReferenceNodeIDs = []string{"hero"}
run, state := agentMediaRun(t, s, args, "request_approval")
if err := s.advanceCloudAgentTool(run, &state); err != nil {
t.Fatal(err)
}
stored, err := s.repo.CloudAgent("user", run.ID)
if err != nil {
t.Fatal(err)
}
state, err = cloudAgentDecode(stored)
if err != nil {
t.Fatal(err)
}
if stored.Status != "running" {
t.Fatalf("repairable media argument failure terminated run: status=%s message=%s", stored.Status, stored.FailureMessage)
}
var failed *CloudAgentEvent
for i := range state.Events {
if state.Events[i].Type == "tool_failed" {
failed = &state.Events[i]
}
if state.Events[i].Type == "run_failed" {
t.Fatalf("argument error emitted run_failed: %+v", state.Events[i])
}
}
if failed == nil {
t.Fatal("missing tool_failed event")
}
result, _ := failed.Payload["result"].(map[string]any)
if result["field"] != "sourceNodeId" || result["issue"] != "invalid_value" || result["requiredAction"] != "fix_arguments" {
t.Fatalf("missing actionable field feedback: %+v", result)
}
var tasks int64
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil {
t.Fatal(err)
}
if tasks != 0 {
t.Fatalf("invalid arguments submitted %d media tasks", tasks)
}
}
func TestCloudAgentUnavailableTransientReferenceIsNotRetryable(t *testing.T) {
s, _, args := agentMediaFixture(t)
args.ReferenceNodeIDs = nil
args.ReferenceTransientIDs = []string{"missing-transient"}
_, _, _, err := cloudAgentMediaDocument(s.repo, "user", "agent-canvas", args, map[string]cloudAgentTransientReference{})
if err == nil {
t.Fatal("missing transient reference was accepted")
}
var argumentErr *cloudAgentArgumentError
if errors.As(err, &argumentErr) {
t.Fatalf("unavailable transient reference was classified as a model-correctable argument: %v", err)
}
class, retryable, action := cloudAgentToolErrorClass(agentTestRequest(), agentMediaCall(args), cloudAgentWrapMediaAdmissionError(err), true)
if class != cloudAgentToolErrorAdmission || retryable || action != "report_to_user" {
t.Fatalf("unavailable transient reference entered correction retry: class=%s retryable=%v action=%s", class, retryable, action)
}
}
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, "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 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", "agent-canvas", 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), `"outputReference":{"ready":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 scenario == "stale-after-approval" {
// Approval now executes its prepared tool synchronously before
// returning; make the canvas stale before that execution boundary.
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", `{"nodes":[],"connections":[]}`).Error; err != nil {
t.Fatal(err)
}
}
if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, decision, ""); err != nil {
t.Fatal(err)
}
if decision == "reject" {
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, "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 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","cat"]}`} {
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, "request_approval")
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", "agent-canvas", 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, "request_approval", "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)
}
}
// 界面承诺“图片、视频始终先创建草稿,再经独立审批才提交”:auto 也必须先审批,
// 审批前不建任务、不扣费;批准后恰好提交一次。
func TestCloudAgentAutoMediaRequiresApprovalBeforeSubmit(t *testing.T) {
s, db, a := agentMediaFixture(t)
// Omitted size and duration should be filled by the selected model's catalog
// defaults during admission instead of producing a repair turn.
a.Size = ""
a.Duration = 0
run, _ := agentMediaRun(t, s, a, "auto")
var ordersBefore int64
if err := db.Model(&model.BillingOrder{}).Count(&ordersBefore).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, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
if run.Status != "waiting_approval" || state.Approval == nil {
t.Fatalf("auto mode submitted media without approval: status=%s", run.Status)
}
var pendingTasks, pendingOrders int64
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&pendingTasks)
db.Model(&model.BillingOrder{}).Count(&pendingOrders)
if pendingTasks != 0 || pendingOrders != ordersBefore {
t.Fatalf("charged/submitted before approval: tasks=%d orders=%d before=%d", pendingTasks, pendingOrders, ordersBefore)
}
if err := s.DecideCloudAgentApproval("user", run.ID, state.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, err = cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
if state.MediaTaskID == "" {
t.Fatalf("approved auto media was not submitted: status=%s", run.Status)
}
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 == "" || input.Config.VideoSeconds == "" {
t.Fatalf("model defaults were not resolved: %+v", input.Config)
}
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"] != "loading" || meta["taskId"] != task.ID || meta["agentDraftRunId"] != nil {
t.Fatalf("approved submission did not finalize the draft: %+v", meta)
}
var tasks, orders int64
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.BillingOrder{}).Count(&orders).Error; err != nil {
t.Fatal(err)
}
if tasks != 1 || orders != ordersBefore+1 {
t.Fatalf("approved submission created unexpected records: tasks=%d orders=%d before=%d", tasks, orders, ordersBefore)
}
// Re-entering a submitted call is idempotent and must not reserve/submit a
// second generation.
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil {
t.Fatal(err)
}
if tasks != 1 {
t.Fatalf("re-entering approved media duplicated task: %d", tasks)
}
// Admission failures are still non-billed tool failures and do not create an
// approval or a draft task.
invalidService, invalidDB, invalidArgs := agentMediaFixture(t)
invalidArgs.ChannelModelKey = "removed-model"
invalidRun, _ := agentMediaRun(t, invalidService, invalidArgs, "auto")
var invalidOrdersBefore int64
if err := invalidDB.Model(&model.BillingOrder{}).Count(&invalidOrdersBefore).Error; err != nil {
t.Fatal(err)
}
if err := invalidService.advanceCloudAgentByID("user", invalidRun.ID); err != nil {
t.Fatal(err)
}
invalidRun, _ = invalidService.repo.CloudAgent("user", invalidRun.ID)
invalidState, err := cloudAgentDecode(invalidRun)
if err != nil {
t.Fatal(err)
}
var invalidTasks, invalidOrders int64
if err := invalidDB.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&invalidTasks).Error; err != nil {
t.Fatal(err)
}
if err := invalidDB.Model(&model.BillingOrder{}).Count(&invalidOrders).Error; err != nil {
t.Fatal(err)
}
if invalidTasks != 0 || invalidOrders != invalidOrdersBefore || invalidState.Approval != nil {
t.Fatalf("invalid auto admission created side effects: tasks=%d orders=%d before=%d approval=%+v", invalidTasks, invalidOrders, invalidOrdersBefore, invalidState.Approval)
}
approvalService, approvalDB, approvalArgs := agentMediaFixture(t)
approvalRun, _ := agentMediaRun(t, approvalService, approvalArgs, "request_approval")
var approvalOrdersBefore int64
if err := approvalDB.Model(&model.BillingOrder{}).Count(&approvalOrdersBefore).Error; err != nil {
t.Fatal(err)
}
if err := approvalService.advanceCloudAgentByID("user", approvalRun.ID); err != nil {
t.Fatal(err)
}
approvalRun, _ = approvalService.repo.CloudAgent("user", approvalRun.ID)
approvalState, err := cloudAgentDecode(approvalRun)
if err != nil {
t.Fatal(err)
}
if approvalRun.Status != "waiting_approval" || approvalState.Approval == nil {
t.Fatalf("request_approval stopped bypassing approval: status=%s approval=%+v", approvalRun.Status, approvalState.Approval)
}
var approvalTasks, approvalOrders int64
if err := approvalDB.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&approvalTasks).Error; err != nil {
t.Fatal(err)
}
if err := approvalDB.Model(&model.BillingOrder{}).Count(&approvalOrders).Error; err != nil {
t.Fatal(err)
}
if approvalTasks != 0 || approvalOrders != approvalOrdersBefore {
t.Fatalf("request_approval submitted before decision: tasks=%d orders=%d before=%d", approvalTasks, approvalOrders, approvalOrdersBefore)
}
}
func TestCloudAgentMediaPromptCountsUnicodeCharacters(t *testing.T) {
s, _, a := agentMediaFixture(t)
run, state := agentMediaRun(t, s, a, "auto")
const mentions = "@图片1 @图片2"
a.Prompt = strings.Repeat("镜", 16000-len([]rune(mentions))) + mentions
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, "request_approval")
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)
generationSpec := metadata["generationSpec"].(map[string]any)
if updated["title"] != "满月庆祝视频草稿(舒缓呼吸感)" || generationSpec["prompt"] != "下一版舒缓视频提示词" {
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)
}
}
func TestCloudAgentMediaDependencyIgnoresNodeMovement(t *testing.T) {
s, _, args := agentMediaFixture(t)
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)
}
nodeList := append(creationMaps(doc["nodes"]), map[string]any{
"id": "video-shot-1", "type": "video", "title": "镜头1视频",
"position": map[string]any{"x": 100.0, "y": 100.0}, "width": 360.0, "height": 640.0,
"metadata": map[string]any{"status": "idle", "prompt": args.Prompt, "agentDraftRunId": "run-current"},
})
normalizedNodes, err := json.Marshal(nodeList)
if err != nil {
t.Fatal(err)
}
var decodedNodes []any
if err := json.Unmarshal(normalizedNodes, &decodedNodes); err != nil {
t.Fatal(err)
}
doc["nodes"] = decodedNodes
args.DraftRunID = "run-current"
base, err := cloudAgentMediaDependencyHash(doc, args)
if err != nil {
t.Fatal(err)
}
moved := cloneCreationDocument(doc)
nodes, err := creationObjects(moved["nodes"])
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"video-shot-1", "cat", "hero", "shot-1"} {
node := nodes[id]
position := node["position"].(map[string]any)
position["x"] = position["x"].(float64) + 240
position["y"] = position["y"].(float64) + 120
}
got, err := cloudAgentMediaDependencyHash(moved, args)
if err != nil {
t.Fatal(err)
}
if got != base {
t.Fatalf("moving target or input nodes changed generation dependency: base=%s got=%s", base, got)
}
}