1023 lines
41 KiB
Go
1023 lines
41 KiB
Go
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)
|
||
}
|
||
}
|