feat(agent): 云端 Agent - 增加上下文预算、任务事实与终态恢复保障
This commit is contained in:
1 parent
3bf21d79af
commit
b52bc9d5f2
61 files changed
+3512
-309
No files matched your search
@@ -0,0 +1,188 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCloudAgentCanvasArgumentFeedbackIdentifiesFailingOperation(t *testing.T) {
|
||||
for _, tc := range []struct{ op, field, issue string }{
|
||||
{`{"type":"add_node","id":"note","nodeType":"text","snapshotHash":"misplaced"}`, "ops[0].snapshotHash", "unexpected_field"},
|
||||
{`{"type":"add_node","nodeType":"text"}`, "ops[0].id", "required"},
|
||||
{`{"type":"add_node","id":"note"}`, "ops[0].nodeType", "required"},
|
||||
{`{"type":"connect_nodes","id":"edge","toNodeId":"target"}`, "ops[0].fromNodeId", "required"},
|
||||
{`{"type":"update_node","id":"note"}`, "ops[0].patch", "required"},
|
||||
{`{"type":"add_node","id":"note","nodeType":"text","x":"private-sentinel"}`, "ops[0].x", "type_mismatch"},
|
||||
{`{"type":"add_node","id":"note","nodeType":"text","private-sentinel":"private-sentinel"}`, "ops[0]", "unexpected_field"},
|
||||
} {
|
||||
t.Run(tc.field+tc.issue, func(t *testing.T) {
|
||||
call := cloudAgentCall{ID: "write"}
|
||||
call.Function.Name, call.Function.Arguments = "canvas_apply_ops", `{"snapshotHash":"snapshot","ops":[`+tc.op+`]}`
|
||||
// Malformed calls must fail before reading, approving, or writing.
|
||||
_, err := prepareCloudAgentCanvasMutation(nil, "user", "canvas", call)
|
||||
if err == nil {
|
||||
t.Fatal("invalid operation accepted")
|
||||
}
|
||||
req := agentTestRequest()
|
||||
req.PermissionMode = "request_approval"
|
||||
state := &cloudAgentRuntime{Canonical: canonicalAgentRequest{Tools: cloudAgentTools(req)}}
|
||||
cloudAgentToolResult("run", state, call, nil, err)
|
||||
content := state.Canonical.Messages[0]["content"].(string)
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["field"] != tc.field || result["issue"] != tc.issue || result["reason"] != "invalid_tool_arguments" || result["parameters"] == nil || strings.Contains(content, "private-sentinel") {
|
||||
t.Fatalf("incorrect repair feedback: %s", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Replay the logged failure with only the misplaced field removed.
|
||||
s, _, _ := agentMediaFixture(t)
|
||||
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
doc, _ := creationDocument(canvas.PayloadJSON)
|
||||
call := cloudAgentCall{ID: "corrected"}
|
||||
call.Function.Name = "canvas_apply_ops"
|
||||
call.Function.Arguments = `{"snapshotHash":"` + cloudAgentCanvasHash(doc) + `","ops":[{"type":"add_node","id":"note","nodeType":"text"}]}`
|
||||
plan, err := prepareCloudAgentCanvasMutation(s.repo, "user", "agent-canvas", call)
|
||||
if err != nil || plan == nil {
|
||||
t.Fatalf("corrected call failed: %v", err)
|
||||
}
|
||||
stored, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
if stored.PayloadJSON != canvas.PayloadJSON {
|
||||
t.Fatal("preview wrote without approval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentGenerationDiagnosticsSeparateOutputAndTask(t *testing.T) {
|
||||
s, db, _ := agentMediaFixture(t)
|
||||
for _, tc := range []struct {
|
||||
name, user, canvas, detail string
|
||||
status model.TaskStatus
|
||||
visible bool
|
||||
}{
|
||||
{"failed", "user", "agent-canvas", "media dimensions must be between 256 and 5760 pixels", model.TaskStatusFailed, true},
|
||||
{"cancelled", "user", "agent-canvas", "任务已取消", model.TaskStatusCancelled, true},
|
||||
{"sensitive", "user", "agent-canvas", "https://private-sentinel.invalid?token=private-sentinel", model.TaskStatusFailed, true},
|
||||
{"other-user", "other", "agent-canvas", "private-sentinel", model.TaskStatusFailed, false},
|
||||
{"other-canvas", "user", "other-canvas", "private-sentinel", model.TaskStatusFailed, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
task := &model.Task{ID: tc.name, UserID: tc.user, ProjectID: tc.canvas, Status: tc.status, Error: tc.detail}
|
||||
if err := db.Create(task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
node := map[string]any{"id": "video", "type": "video", "metadata": map[string]any{"status": "loading", "taskId": task.ID}}
|
||||
doc := map[string]any{"nodes": []map[string]any{node}}
|
||||
view, err := cloudAgentCanvasState(s.repo, "user", "agent-canvas", doc, 0, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := view.(map[string]any)["nodes"].([]any)[0].(map[string]any)
|
||||
generation := item["generation"].(map[string]any)
|
||||
output := item["outputReference"].(map[string]any)
|
||||
if output["ready"] != false || output["issue"] == nil || generation["submitBlockedReason"] != "task_bound" {
|
||||
t.Fatalf("ambiguous state: %+v", item)
|
||||
}
|
||||
if tc.visible {
|
||||
if generation["taskId"] != task.ID || generation["taskStatus"] != string(tc.status) || generation["error"] == nil {
|
||||
t.Fatalf("lost task failure: %+v", generation)
|
||||
}
|
||||
if tc.name == "failed" && generation["error"] != tc.detail {
|
||||
t.Fatalf("lost useful diagnostic: %+v", generation)
|
||||
}
|
||||
} else if generation["taskStatus"] != "unavailable" || generation["taskId"] != nil || generation["error"] != nil {
|
||||
t.Fatalf("task ownership leaked: %+v", generation)
|
||||
}
|
||||
raw, _ := json.Marshal(view)
|
||||
if strings.Contains(string(raw), "private-sentinel") {
|
||||
t.Fatal("unsafe error leaked")
|
||||
}
|
||||
call := cloudAgentCall{ID: "query"}
|
||||
call.Function.Name, call.Function.Arguments = "task_get", `{"taskId":"`+task.ID+`"}`
|
||||
result, err := cloudAgentReadTool(s.repo, "user", &cloudAgentRuntime{Request: agentTestRequest()}, call)
|
||||
if tc.visible {
|
||||
if err != nil || result.(map[string]any)["error"] != generation["error"] {
|
||||
t.Fatalf("task_get lost diagnostic: %+v %v", result, err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("task_get crossed ownership boundary")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentMediaAdmissionReportsTaskBinding(t *testing.T) {
|
||||
s, db, args := agentMediaFixture(t)
|
||||
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
|
||||
doc, _ := creationDocument(canvas.PayloadJSON)
|
||||
doc["nodes"] = append(creationMaps(doc["nodes"]), map[string]any{"id": args.NodeID, "type": "video", "metadata": map[string]any{"status": "error", "taskId": "original-task"}})
|
||||
raw, _ := json.Marshal(doc)
|
||||
if err := db.Model(canvas).Update("payload_json", string(raw)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args.SnapshotHash, args.DraftRunID = cloudAgentMediaContentHash(doc), "current-run"
|
||||
_, _, _, err := cloudAgentMediaDocument(s.repo, "user", "agent-canvas", args)
|
||||
if err == nil {
|
||||
t.Fatal("existing task overwritten")
|
||||
}
|
||||
state := &cloudAgentRuntime{}
|
||||
cloudAgentToolResult("run", state, agentMediaCall(args), map[string]any{"phase": "admission", "taskSubmitted": false}, err)
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(state.Canonical.Messages[0]["content"].(string)), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["reason"] != "task_bound" || result["nodeId"] != args.NodeID || result["taskSubmitted"] != false {
|
||||
t.Fatalf("missing admission diagnosis: %+v", result)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("admission submitted task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentCancellationRecordsSourceOnce(t *testing.T) {
|
||||
s, _, args := agentMediaFixture(t)
|
||||
run, _ := agentMediaRun(t, s, args, "auto")
|
||||
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveAgentMediaDraft(t, s, run.ID)
|
||||
run, _ = s.repo.CloudAgent("user", run.ID)
|
||||
before, err := cloudAgentDecode(run)
|
||||
if err != nil || before.MediaTaskID == "" {
|
||||
t.Fatalf("missing media task: %v", err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := s.CancelCloudAgent(context.Background(), "user", run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
run, _ = s.repo.CloudAgent("user", run.ID)
|
||||
after, err := cloudAgentDecode(run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
for _, event := range after.Events {
|
||||
if event.Type != "run_cancelled" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if event.Payload["source"] != "user_request" || event.Payload["mediaTaskId"] != before.MediaTaskID {
|
||||
t.Fatalf("missing cancel source: %+v", event)
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one cancellation event, got %d", count)
|
||||
}
|
||||
task, _ := s.repo.TaskForUser("user", before.MediaTaskID)
|
||||
if task.Status != model.TaskStatusCancelled {
|
||||
t.Fatalf("child not cancelled: %s", task.Status)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user