Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_node_generation_supersede_test.go
ddcat666 10ea9d3fa0 feat(canvas): Agent 审批 - 节点直接生成时自动关闭同节点的待审批任务
用户在画布节点上直接提交生成后,同画布 Agent 对该节点等待中的 generate_media 审批关闭为 superseded_by_node,Agent 带着「用户已手动提交」的结果继续,不重复提交、不扣费。审批卡片识别目标节点正在生成时禁用「同意执行」并提示原因。
2026-09-30 03:40:14 +08:00

136 lines
6.3 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 (
"strings"
"testing"
"infinite-canvas/backend/internal/model"
)
// waitingAgentMediaApproval 把 Agent 推进到“等待生成审批”,返回运行和审批 ID。
func waitingAgentMediaApproval(t *testing.T, s *Service, a cloudAgentMediaArgs) (*model.CloudAgentExecution, string) {
t.Helper()
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, err := cloudAgentDecode(run)
if err != nil || run.Status != "waiting_approval" || state.Approval == nil {
t.Fatalf("expected waiting approval: status=%s err=%v", run.Status, err)
}
return run, state.Approval.ID
}
// userNodeTaskRequest 复用 Agent 同一份生成请求,但以普通画布节点生成的身份提交。
func userNodeTaskRequest(t *testing.T, s *Service, run *model.CloudAgentExecution, metadata map[string]any) CreateTaskRequest {
t.Helper()
state, _ := cloudAgentDecode(run)
req, _, err := s.prepareCloudAgentMedia(run, &state, state.Calls[state.CallIndex])
if err != nil {
t.Fatal(err)
}
req.Input["metadata"] = metadata
return req
}
func TestNodeGenerationSupersedesMatchingAgentApproval(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, approvalID := waitingAgentMediaApproval(t, s, a)
req := userNodeTaskRequest(t, s, run, map[string]any{"nodeId": a.NodeID, "source": "canvas"})
task, err := s.CreateTask("user", req)
if 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 != "running" || state.Approval != nil || state.Decisions[approvalID] != cloudAgentApprovalSuperseded || state.CallIndex != 1 {
t.Fatalf("approval not superseded: status=%s approval=%+v decision=%q index=%d", run.Status, state.Approval, state.Decisions[approvalID], state.CallIndex)
}
decided := false
for _, event := range state.Events {
if event.Type == "approval_decided" && event.Payload["decision"] == cloudAgentApprovalSuperseded {
decided = event.Payload["approvalId"] == approvalID && event.Payload["taskId"] == task.ID && event.Payload["nodeId"] == a.NodeID
}
}
if !decided {
t.Fatal("missing superseded approval_decided event")
}
content, ok := cloudAgentToolMessage(&state, "media-call")
if !ok || !strings.Contains(content, "superseded_by_user") || !strings.Contains(content, task.ID) || cloudAgentToolContentIsError(content) {
t.Fatalf("tool result does not tell the Agent the user submitted: %q", content)
}
if state.MediaTaskID != "" || state.Generations != 0 {
t.Fatalf("Agent took over the user task: media=%s generations=%d", state.MediaTaskID, state.Generations)
}
var count, leases int64
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
db.Model(&model.CloudAgentResourceLease{}).Where("owner_id = ?", approvalID).Count(&leases)
if count != 1 || leases != 0 {
t.Fatalf("expected only the user task and released leases: tasks=%d leases=%d", count, leases)
}
// 卡片上的“同意/拒绝”晚到时不能再提交任务,并给出可读原因。
for _, decision := range []string{"approve", "reject"} {
err = s.DecideCloudAgentApproval("user", run.ID, approvalID, decision, "")
if err == nil || !strings.Contains(err.Error(), "直接生成") {
t.Fatalf("late %s must be rejected with a superseded reason: %v", decision, err)
}
}
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
if count != 1 {
t.Fatalf("late approval submitted a duplicate task: %d", count)
}
}
func TestNodeGenerationLeavesUnrelatedAgentApprovals(t *testing.T) {
for name, metadata := range map[string]map[string]any{
"other node": {"nodeId": "hero", "source": "canvas"},
"no node": {"source": "canvas"},
"agent origin": {"nodeId": "video-shot-1", "source": "cloud_agent"},
} {
t.Run(name, func(t *testing.T) {
s, _, a := agentMediaFixture(t)
run, approvalID := waitingAgentMediaApproval(t, s, a)
state, _ := cloudAgentDecode(run)
s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "user-task", ProjectID: "agent-canvas"}, map[string]any{"metadata": metadata})
if name == "agent origin" {
// 显式带 AgentRunID 的任务同样跳过。
s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "agent-task", ProjectID: "agent-canvas", AgentRunID: run.ID}, map[string]any{"metadata": map[string]any{"nodeId": a.NodeID}})
}
// 其他画布上的同名节点也不能关闭本画布的审批。
s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "other-canvas-task", ProjectID: "other-canvas"}, map[string]any{"metadata": map[string]any{"nodeId": a.NodeID}})
after, _ := s.repo.CloudAgent("user", run.ID)
afterState, _ := cloudAgentDecode(after)
if after.Status != "waiting_approval" || afterState.Approval == nil || afterState.Approval.ID != approvalID || afterState.CallIndex != state.CallIndex {
t.Fatalf("unrelated task changed the approval: status=%s approval=%+v", after.Status, afterState.Approval)
}
if err := s.DecideCloudAgentApproval("user", run.ID, approvalID, "reject", ""); err != nil {
t.Fatalf("approval should still be decidable: %v", err)
}
})
}
}
func TestNodeGenerationSupersedeViaSourceNode(t *testing.T) {
s, _, a := agentMediaFixture(t)
run, approvalID := waitingAgentMediaApproval(t, s, a)
// 批量图片的结果落在新子节点,sourceNodeId 才是被审批的那个节点。
s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "batch-child-task", ProjectID: "agent-canvas"}, map[string]any{"metadata": map[string]any{"nodeId": "batch-child", "sourceNodeId": a.NodeID}})
after, _ := s.repo.CloudAgent("user", run.ID)
state, _ := cloudAgentDecode(after)
if state.Decisions[approvalID] != cloudAgentApprovalSuperseded || state.Approval != nil {
t.Fatalf("sourceNodeId did not supersede the approval: %+v", state.Approval)
}
// 重复通知是幂等的:不会再写第二条工具结果。
s.supersedeCloudAgentApprovalsForNodeTask("user", &model.Task{ID: "batch-child-task-2", ProjectID: "agent-canvas"}, map[string]any{"metadata": map[string]any{"nodeId": a.NodeID}})
again, _ := s.repo.CloudAgent("user", run.ID)
if again.Revision != after.Revision {
t.Fatalf("repeated supersede mutated the run: %d -> %d", after.Revision, again.Revision)
}
}