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

219 lines
8.5 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"
)
func TestCloudAgentPlanUpdateAndPending(t *testing.T) {
state := &cloudAgentRuntime{}
call := cloudAgentCall{ID: "plan-1"}
call.Function.Name = "plan_update"
call.Function.Arguments = `{"items":[{"id":"1","title":"生成镜头1","status":"doing"},{"id":"2","title":"生成镜头2","status":"pending"}]}`
result, err := cloudAgentApplyPlanUpdate(state, call)
if err != nil {
t.Fatal(err)
}
body, _ := result.(map[string]any)
pending, _ := body["pendingTitles"].([]string)
if len(state.Plan) != 2 || len(pending) != 2 || pending[0] != "生成镜头1" {
t.Fatalf("plan not applied: %+v pending=%v", state.Plan, pending)
}
if preview, ok := cloudAgentPlanApprovalPreview(call); !ok || preview.Kind != "plan" || len(preview.Items) != 2 {
t.Fatalf("first-plan approval preview missing: %+v ok=%v", preview, ok)
}
}
func TestCloudAgentPlanRequiresFirstApprovalOnlyInRequestApproval(t *testing.T) {
call := cloudAgentCall{ID: "plan-1"}
call.Function.Name = "plan_update"
call.Function.Arguments = `{"items":[{"id":"1","title":"生成镜头1","status":"pending"},{"id":"2","title":"生成镜头2","status":"pending"}]}`
auto := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto"}}
if cloudAgentPlanRequiresFirstApproval(auto, call) {
t.Fatal("auto 模式不应为首次清单弹审批")
}
readOnly := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "read_only"}}
if cloudAgentPlanRequiresFirstApproval(readOnly, call) {
t.Fatal("只读模式不应为首次清单弹审批")
}
need := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "request_approval"}}
if !cloudAgentPlanRequiresFirstApproval(need, call) {
t.Fatal("request_approval 首次 ≥2 项清单应当弹审批")
}
need.Plan = []cloudAgentPlanItem{{ID: "1", Title: "已有", Status: "doing"}}
if cloudAgentPlanRequiresFirstApproval(need, call) {
t.Fatal("清单已存在时不应再弹首次审批")
}
}
func TestCloudAgentInheritedPlanReachesFirstModelRequest(t *testing.T) {
s, db, root := reliableAgentRoot(t)
run, state := agentInterjectionState(t, s, root.ID)
state.Plan = []cloudAgentPlanItem{{ID: "1", Title: "生成镜头2视频", Status: "doing"}}
state.ConfirmationRounds = 1
if err := cloudAgentSave(run, &state); err != nil {
t.Fatal(err)
}
if err := db.Save(run).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.Task{}).Where("id = ?", root.ID).Updates(map[string]any{
"status": model.TaskStatusSucceeded, "result_json": `{"text":"先停在这里"}`,
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.CloudAgentExecution{}).Where("id = ?", root.ID).Update("status", "completed").Error; err != nil {
t.Fatal(err)
}
next := agentTestRequest()
next.IdempotencyKey = "agent-plan-inherit-key"
next.Prompt = "继续做镜头2"
child, err := s.CreateCloudAgentRun("user", next, root.ID)
if err != nil {
t.Fatal(err)
}
var task model.Task
if err := db.First(&task, "id = ?", child.ID).Error; err != nil {
t.Fatal(err)
}
if !strings.Contains(task.InputJSON, "plan_state") || !strings.Contains(task.InputJSON, "生成镜头2视频") {
t.Fatalf("继承清单没有进入第一拍模型请求: %s", task.InputJSON)
}
_, childState := agentInterjectionState(t, s, child.ID)
if strings.Contains(childState.Canonical.SystemPrompt, "本轮待办清单") {
t.Fatal("运行态系统提示不应长期钉死上一轮的清单快照,后续应按当前 Plan 重拼")
}
if len(childState.Plan) != 1 || childState.Plan[0].Title != "生成镜头2视频" {
t.Fatalf("运行态没有继承清单: %+v", childState.Plan)
}
if childState.ConfirmationRounds != 1 {
t.Fatalf("运行态没有继承确认轮次: %d", childState.ConfirmationRounds)
}
wired := cloudAgentCanonicalWithPlan(&childState)
if strings.Contains(wired.SystemPrompt, "生成镜头2视频") || strings.Contains(wired.SystemPrompt, "本轮待办清单") {
t.Fatalf("清单不得写进系统提示,否则会打爆前缀缓存: %s", wired.SystemPrompt)
}
if len(wired.Messages) == 0 {
t.Fatal("后续调用缺少会话")
}
last := wired.Messages[len(wired.Messages)-1]
if stringField(last, "role") != "user" || !strings.Contains(stringField(last, "content"), "生成镜头2视频") {
t.Fatalf("后续调用没有把当前清单挂在末尾消息: %+v", last)
}
}
func TestCloudAgentAskUserRequiresChoices(t *testing.T) {
call := cloudAgentCall{ID: "ask-1"}
call.Function.Name = "ask_user"
call.Function.Arguments = `{"question":"用哪个模型?","options":[{"label":"A"},{"label":"B","detail":"更稳"}]}`
result, err := cloudAgentAskUser(call)
if err != nil {
t.Fatal(err)
}
body, _ := result.(map[string]any)
if body["question"] != "用哪个模型?" || body["phase"] != "question" {
t.Fatalf("ask_user result = %+v", body)
}
call.Function.Arguments = `{"question":"只有一个选项","options":[{"label":"A"}]}`
if _, err := cloudAgentAskUser(call); err == nil {
t.Fatal("ask_user accepted a single option")
}
}
func TestCloudAgentAskUserTracksConfirmationRoundsAndDefaults(t *testing.T) {
call := cloudAgentCall{ID: "ask-1"}
call.Function.Name = "ask_user"
call.Function.Arguments = `{"question":"选哪个方向?","options":[{"label":"A"},{"label":"B"}]}`
state := &cloudAgentRuntime{}
for round := 1; round <= cloudAgentMaxConfirmationRounds; round++ {
result, err := cloudAgentAskUser(call, state)
if err != nil {
t.Fatal(err)
}
body := result.(map[string]any)
if body["phase"] != "question" || body["round"] != round || body["maxRounds"] != cloudAgentMaxConfirmationRounds {
t.Fatalf("round %d payload = %+v", round, body)
}
}
result, err := cloudAgentAskUser(call, state)
if err != nil {
t.Fatal(err)
}
body := result.(map[string]any)
if body["phase"] != "defaulted" || body["defaulted"] != true || state.ConfirmationRounds != cloudAgentMaxConfirmationRounds {
t.Fatalf("exhausted ask_user payload/state = %+v / %+v", body, state)
}
}
func TestCloudAgentPlanNudgePrefersLatestUserInstruction(t *testing.T) {
state := &cloudAgentRuntime{
Canonical: canonicalAgentRequest{Messages: []map[string]any{{"role": "user", "content": "改成 16:9"}}},
}
content := stringField(cloudAgentPlanNudgeMessage(state, "生成镜头2视频"), "content")
if !strings.Contains(content, "改成 16:9") || !strings.Contains(content, "生成镜头2视频") || !strings.Contains(content, "pending_plan") {
t.Fatalf("nudge missing user-first prefix: %s", content)
}
}
func TestCloudAgentPendingPlanPreventsTextOnlyCompletion(t *testing.T) {
s, db, root := reliableAgentRoot(t)
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.Plan = []cloudAgentPlanItem{{ID: "1", Title: "生成镜头1", Status: "doing"}}
if err := cloudAgentSave(run, &state); err != nil {
t.Fatal(err)
}
if err := db.Save(run).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.Task{}).Where("id = ?", root.ID).Updates(map[string]any{
"status": model.TaskStatusSucceeded, "result_json": `{"text":"先看到这里"}`,
}).Error; err != nil {
t.Fatal(err)
}
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
t.Fatal(err)
}
run, state = agentInterjectionState(t, s, root.ID)
if run.Status != "running" {
t.Fatalf("未完成清单时不该收尾,status=%s", run.Status)
}
found := false
for _, message := range state.Canonical.Messages {
content, _ := message["content"].(string)
if strings.Contains(content, "生成镜头1") && strings.Contains(content, "pending_plan") {
found = true
}
}
if !found {
t.Fatalf("没有催办未完成项: %+v", state.Canonical.Messages)
}
if state.Canonical.ToolChoice != "auto" {
t.Fatal("旧计划催办不得强制工具调用")
}
// An unchanged plan gets one reminder, not an endless loop of paid steps.
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
t.Fatal(err)
}
_, state = agentInterjectionState(t, s, root.ID)
if err := db.Model(&model.Task{}).Where("id = ?", state.ActiveTaskID).Updates(map[string]any{
"status": model.TaskStatusSucceeded, "result_json": `{"text":"按最新要求停止生成,保留草稿。"}`,
}).Error; err != nil {
t.Fatal(err)
}
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
t.Fatal(err)
}
run, state = agentInterjectionState(t, s, root.ID)
if run.Status != "completed" || state.Plan[0].Status == "done" {
t.Fatalf("应允许结束对话而不伪造计划完成: status=%s plan=%+v", run.Status, state.Plan)
}
}