package application import ( "context" "errors" "strings" "testing" "time" "img_infinite_canvas/internal/domain/design" "img_infinite_canvas/internal/infrastructure/memory" ) func TestMarkGenerationFailedRemovesCanvasPlaceholders(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) project := design.Project{ ID: "project-1", Title: "Failure cleanup", Brief: "prompt", Status: design.StatusExploring, UpdatedAt: time.Now(), Nodes: []design.Node{ { ID: "existing-image", Type: design.NodeTypeImage, Title: "Existing", Content: "https://example.com/existing.png", Status: "success", }, { ID: "placeholder", Type: design.NodeTypeImage, Title: "GPT Image 2 生成中", Content: "make a poster", Status: "generating", }, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } if err := service.markGenerationFailed(ctx, project.ID, errors.New("image generation failed")); err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 1 { t.Fatalf("expected failed placeholder to be removed, got %#v", saved.Nodes) } if saved.Nodes[0].ID != "existing-image" { t.Fatalf("expected existing node to remain, got %#v", saved.Nodes[0]) } if saved.Status != design.StatusFailed { t.Fatalf("expected project failed, got %s", saved.Status) } if len(saved.Messages) != 1 || saved.Messages[0].Role != "error" { t.Fatalf("expected error message, got %#v", saved.Messages) } } func TestMarkGenerationFailedDoesNotExposeInternalEndpoint(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) project := design.Project{ ID: "project-safe-error", Title: "Safe failure", Brief: "prompt", Status: design.StatusExploring, UpdatedAt: time.Now(), Nodes: []design.Node{ {ID: "placeholder", Type: design.NodeTypeImage, Title: "生成中", Content: "prompt", Status: "generating"}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } failure := errors.New(`gpt image 2 generation failed: Post "http://154.21.87.3:8080/v1/images/generations": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`) if err := service.markGenerationFailed(ctx, project.ID, failure); err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } content := saved.Messages[len(saved.Messages)-1].Content if strings.Contains(content, "154.21.87.3") || strings.Contains(content, "/v1/images/generations") || strings.Contains(content, "Client.Timeout") { t.Fatalf("expected sanitized user-facing failure, got %q", content) } if !strings.Contains(content, "图片生成耗时较久") { t.Fatalf("expected friendly timeout message, got %q", content) } } func TestMarkGenerationFailedKeepsRealImageNodeActionTarget(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) project := design.Project{ ID: "project-2", Title: "Node action failure", Brief: "prompt", Status: design.StatusExploring, UpdatedAt: time.Now(), Nodes: []design.Node{ { ID: "target-image", Type: design.NodeTypeImage, Title: "快捷编辑生成中", Content: "https://example.com/source.png", Status: "generating", }, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } if err := service.markGenerationFailed(ctx, project.ID, errors.New("edit failed")); err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 1 { t.Fatalf("expected real image target to remain, got %#v", saved.Nodes) } if saved.Nodes[0].Status != "error" { t.Fatalf("expected real image target status error, got %s", saved.Nodes[0].Status) } } func TestGetProjectRemovesStaleFailedPlaceholders(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) project := design.Project{ ID: "project-3", Title: "Stale failure cleanup", Brief: "prompt", Status: design.StatusFailed, UpdatedAt: time.Now(), Nodes: []design.Node{ { ID: "stale-placeholder", Type: design.NodeTypeImage, Title: "GPT Image 2 生成中", Content: "make a poster", Status: "error", }, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } saved, err := service.GetProject(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 0 { t.Fatalf("expected stale failed placeholder to be removed, got %#v", saved.Nodes) } } func TestGetProjectRemovesLegacyMockCanvasNodes(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) project := design.Project{ ID: "project-4", Title: "Legacy mock cleanup", Brief: "prompt", Status: design.StatusReady, UpdatedAt: time.Now(), Nodes: []design.Node{ { ID: "mock-image", Type: design.NodeTypeImage, Title: "Mood x Color", Content: "prompt", Status: "success", }, { ID: "mock-frame", Type: design.NodeTypeFrame, Title: "Soft Variant", Content: "prompt", Status: "success", }, { ID: "manual-frame", Type: design.NodeTypeFrame, Title: "Frame", LayerRole: "manual-frame", Status: "success", }, { ID: "uploaded-image", Type: design.NodeTypeImage, Title: "Uploaded", Content: "/canvas-assets/uploaded.png", Status: "success", }, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } saved, err := service.GetProject(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 2 { t.Fatalf("expected only real canvas nodes to remain, got %#v", saved.Nodes) } if saved.Nodes[0].ID != "manual-frame" || saved.Nodes[1].ID != "uploaded-image" { t.Fatalf("unexpected remaining nodes: %#v", saved.Nodes) } } type fakeCreativeChatAgent struct { decision design.AgentDecision response string taskPlan design.AgentTaskPlan } func (a fakeCreativeChatAgent) Decide(ctx context.Context, req design.AgentDecisionRequest) (design.AgentDecision, error) { return a.decision, nil } func (a fakeCreativeChatAgent) Respond(ctx context.Context, req design.AgentConversationRequest) (string, error) { return a.response, nil } func (a fakeCreativeChatAgent) SummarizeImage(ctx context.Context, req design.AgentImageSummaryRequest) (string, error) { return "", nil } func (a fakeCreativeChatAgent) BuildImagePrompt(ctx context.Context, req design.AgentImagePromptRequest) (design.AgentImagePrompt, error) { return design.AgentImagePrompt{}, nil } func (a fakeCreativeChatAgent) PlanAgentTask(ctx context.Context, req design.AgentTaskPlanRequest) (design.AgentTaskPlan, error) { if strings.TrimSpace(a.taskPlan.Intent) != "" { return a.taskPlan, nil } return design.AgentTaskPlan{ Intent: a.decision.Intent, Reason: a.decision.Reason, UserFacingResponse: "我会先拆解视觉交付项,再开始生成。", ImageCount: 1, ImageTasks: []design.AgentImageTask{{Title: "生成图片", Brief: req.Prompt}}, }, nil } type recordingTaskPlanner struct { fakeCreativeChatAgent called int } func (a *recordingTaskPlanner) PlanAgentTask(ctx context.Context, req design.AgentTaskPlanRequest) (design.AgentTaskPlan, error) { a.called++ return a.fakeCreativeChatAgent.PlanAgentTask(ctx, req) } type inspectingCreateProjectAgent struct { repo design.Repository t *testing.T expected int } func (a inspectingCreateProjectAgent) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) { project, err := a.repo.Get(ctx, req.Project.ID) if err != nil { a.t.Fatal(err) } count := 0 for _, node := range project.Nodes { if node.Type == design.NodeTypeImage && node.Status == "generating" { count++ } } if count != a.expected { a.t.Fatalf("expected %d async placeholders before image generation, got %#v", a.expected, project.Nodes) } return design.AgentPlan{ Message: design.Message{Role: "assistant", Type: "assistant", Title: "生成完成", Content: "done"}, GeneratedNodes: []design.Node{ {ID: "generated-1", Type: design.NodeTypeImage, Title: "正面", Content: "https://example.com/front.png", Status: "success", Width: 1024, Height: 1024}, {ID: "generated-2", Type: design.NodeTypeImage, Title: "侧面", Content: "https://example.com/side.png", Status: "success", Width: 1024, Height: 1024}, {ID: "generated-3", Type: design.NodeTypeImage, Title: "细节", Content: "https://example.com/detail.png", Status: "success", Width: 1024, Height: 1024}, }, }, nil } func (a inspectingCreateProjectAgent) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) { return design.AgentReplay{}, nil } type incrementalCreateProjectAgent struct { repo design.Repository t *testing.T } func (a incrementalCreateProjectAgent) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) { return design.AgentPlan{}, errors.New("expected incremental generation") } func (a incrementalCreateProjectAgent) PlanIncremental(ctx context.Context, req design.AgentRequest, onResult func(design.AgentImageGenerationResult) error) (design.AgentPlan, error) { first := design.Node{ID: req.Project.Nodes[0].ID, Type: design.NodeTypeImage, Title: "卡背设计", Content: "https://example.com/card-back.png", Status: "success", Width: 1024, Height: 1024} if err := onResult(design.AgentImageGenerationResult{Index: 0, Node: first}); err != nil { return design.AgentPlan{}, err } project, err := a.repo.Get(ctx, req.Project.ID) if err != nil { a.t.Fatal(err) } successCount := 0 generatingCount := 0 for _, node := range project.Nodes { if node.Status == "success" { successCount++ } if node.Status == "generating" { generatingCount++ } } if successCount != 1 || generatingCount != 1 { a.t.Fatalf("expected first image to be saved while second is still generating, got %#v", project.Nodes) } second := design.Node{ID: req.Project.Nodes[1].ID, Type: design.NodeTypeImage, Title: "卡面信息层级", Content: "https://example.com/card-front.png", Status: "success", Width: 1024, Height: 1024} if err := onResult(design.AgentImageGenerationResult{Index: 1, Node: second}); err != nil { return design.AgentPlan{}, err } return design.AgentPlan{ Message: design.Message{Role: "assistant", Type: "assistant", Title: "图片生成完成", Content: "两张图已生成。"}, GeneratedNodes: []design.Node{first, second}, }, nil } func (a incrementalCreateProjectAgent) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) { return design.AgentReplay{}, nil } type staleOverwriteIncrementalCreateProjectAgent struct { repo design.Repository t *testing.T } func (a staleOverwriteIncrementalCreateProjectAgent) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) { return design.AgentPlan{}, errors.New("expected incremental generation") } func (a staleOverwriteIncrementalCreateProjectAgent) PlanIncremental(ctx context.Context, req design.AgentRequest, onResult func(design.AgentImageGenerationResult) error) (design.AgentPlan, error) { if len(req.Project.Nodes) == 0 { a.t.Fatal("expected planned placeholder before image generation") } staleProject := req.Project node := design.Node{ID: req.Project.Nodes[0].ID, Type: design.NodeTypeImage, Title: "盲盒IP视觉体系", Content: "https://example.com/blind-box-system.png", Status: "success", Width: 1024, Height: 1024} if err := onResult(design.AgentImageGenerationResult{Index: 0, Node: node}); err != nil { return design.AgentPlan{}, err } if err := a.repo.Save(ctx, staleProject); err != nil { return design.AgentPlan{}, err } return design.AgentPlan{ Message: design.Message{Role: "assistant", Type: "assistant", Title: "图片生成完成", Content: "盲盒IP视觉体系已生成。"}, GeneratedNodes: []design.Node{node}, }, nil } func (a staleOverwriteIncrementalCreateProjectAgent) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) { return design.AgentReplay{}, nil } type partialFailingIncrementalCreateProjectAgent struct{} func (a partialFailingIncrementalCreateProjectAgent) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) { return design.AgentPlan{}, errors.New("expected incremental generation") } func (a partialFailingIncrementalCreateProjectAgent) PlanIncremental(ctx context.Context, req design.AgentRequest, onResult func(design.AgentImageGenerationResult) error) (design.AgentPlan, error) { if len(req.Project.Nodes) < 2 { return design.AgentPlan{}, errors.New("expected two planned placeholders") } first := design.Node{ID: req.Project.Nodes[0].ID, Type: design.NodeTypeImage, Title: "方案一", Content: "https://example.com/option-1.png", Status: "success", Width: 1024, Height: 1024} if err := onResult(design.AgentImageGenerationResult{Index: 0, Node: first}); err != nil { return design.AgentPlan{}, err } return design.AgentPlan{ Message: design.Message{Role: "assistant", Type: "assistant", Title: "图片生成完成", Content: "部分图片已生成。"}, GeneratedNodes: []design.Node{first}, }, errors.New("upstream completed but local response stream ended before image 2") } func (a partialFailingIncrementalCreateProjectAgent) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) { return design.AgentReplay{}, nil } type failingAgentRunner struct { err error } func (a failingAgentRunner) Plan(ctx context.Context, req design.AgentRequest) (design.AgentPlan, error) { return design.AgentPlan{}, a.err } func (a failingAgentRunner) Replay(ctx context.Context, project design.Project) (design.AgentReplay, error) { return design.AgentReplay{}, nil } type fakeResearchDecisionAgent struct { decision design.AgentResearchDecision memory design.AgentMemory } func (a *fakeResearchDecisionAgent) Decide(ctx context.Context, req design.AgentDecisionRequest) (design.AgentDecision, error) { return design.AgentDecision{Intent: "chat"}, nil } func (a *fakeResearchDecisionAgent) Respond(ctx context.Context, req design.AgentConversationRequest) (string, error) { return "", nil } func (a *fakeResearchDecisionAgent) SummarizeImage(ctx context.Context, req design.AgentImageSummaryRequest) (string, error) { return "", nil } func (a *fakeResearchDecisionAgent) BuildImagePrompt(ctx context.Context, req design.AgentImagePromptRequest) (design.AgentImagePrompt, error) { return design.AgentImagePrompt{}, nil } func (a *fakeResearchDecisionAgent) DecideResearch(ctx context.Context, req design.AgentResearchDecisionRequest) (design.AgentResearchDecision, error) { a.memory = req.Memory return a.decision, nil } type fakeResearcher struct { called bool query string } func (r *fakeResearcher) Research(ctx context.Context, prompt string) (design.ResearchReport, error) { r.called = true r.query = prompt return design.ResearchReport{ Kind: "web_search", Source: "test", Query: prompt, Results: []design.ResearchResult{ {Title: "Reference", URL: "https://example.com", Snippet: "visual reference"}, }, }, nil } func TestAgentChatUsesCreativeAgentForConversationWithoutImageGeneration(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil, fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "chat", Reason: "greeting"}, response: "Hi,我在。你可以继续给我一个具体作图目标。", }) project := design.Project{ ID: "project-5", Title: "Conversation", Brief: "still day 官网首页", Status: design.StatusReady, UpdatedAt: time.Now(), } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{ Messages: []design.AgentChatMessage{ { Role: "user", Contents: []design.AgentContent{ {Type: "text", Text: "你好"}, }, }, }, }) if err != nil { t.Fatal(err) } if thread.Status != "running" { t.Fatalf("expected running thread, got %s", thread.Status) } if len(thread.Project.Nodes) != 0 { t.Fatalf("expected no image placeholder for chat, got %#v", thread.Project.Nodes) } var saved design.Project for attempt := 0; attempt < 20; attempt++ { saved, err = repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if saved.Status == design.StatusReady && len(saved.Messages) == 2 && saved.Messages[1].Status == "success" { break } time.Sleep(10 * time.Millisecond) } if len(saved.Messages) != 2 { t.Fatalf("expected user and assistant messages, got %#v", saved.Messages) } if saved.Messages[1].Content != "Hi,我在。你可以继续给我一个具体作图目标。" { t.Fatalf("expected creative response, got %s", saved.Messages[1].Content) } } func TestAgentChatRequiresTextPrompt(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil, fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image"}, }) project := design.Project{ ID: "project-requires-text", Title: "Requires text", Status: design.StatusReady, UpdatedAt: time.Now(), } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } _, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{ Messages: []design.AgentChatMessage{ { Role: "user", Contents: []design.AgentContent{ {Type: "image", ImageURL: "https://example.com/reference.png"}, {Type: "text", Text: "图片生成参数:质量 自动,尺寸 1024x1024,比例 1:1,数量 1 张。", TextSource: "settings"}, }, }, }, }) if !errors.Is(err, design.ErrInvalidInput) { t.Fatalf("expected invalid input for image-only chat, got %v", err) } } func TestAgentChatCreatesPlaceholdersFromModelPlannedImageTasks(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil, fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image", Reason: "user requested posters"}, taskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我将拆成四张角色设计图。", ImageCount: 4, ImageTasks: []design.AgentImageTask{ {Title: "水墨角色_正面", Brief: "正面全身立绘"}, {Title: "水墨角色_侧面", Brief: "侧面全身立绘"}, {Title: "水墨角色_细节", Brief: "局部细节拆解"}, {Title: "水墨角色_色彩方案", Brief: "色彩方案设计"}, }, }, }) service.SetJobQueue(&fakeJobQueue{}) project := design.Project{ ID: "project-count-placeholders", Title: "Count placeholders", Status: design.StatusReady, UpdatedAt: time.Now(), } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{ Messages: []design.AgentChatMessage{ { Role: "user", Contents: []design.AgentContent{ {Type: "text", Text: "设计二次元水墨人物形象,输出角色正面、侧面、细节和色彩方案。"}, }, }, }, }) if err != nil { t.Fatal(err) } if len(thread.Project.Nodes) != 4 { t.Fatalf("expected 4 placeholders, got %#v", thread.Project.Nodes) } for index, node := range thread.Project.Nodes { if node.Type != design.NodeTypeImage || node.Status != "generating" { t.Fatalf("expected generating image placeholder, got %#v", node) } if node.Title == "" || node.Title == generationPlaceholderTitle("设计二次元水墨人物形象,输出角色正面、侧面、细节和色彩方案。") { t.Fatalf("expected planned task title for node %d, got %#v", index, node) } } } func TestAgentChatAllowsPlannerToClarifyVisualBriefBeforeGenerating(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() queue := &fakeJobQueue{} service := NewDesignService(repo, nil, nil, nil, nil, fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image", Reason: "visual request"}, taskPlan: design.AgentTaskPlan{ Intent: "clarify", UserFacingResponse: "开始生图前我需要确认:Logo 是否已有?主视觉偏大胆色彩还是极简?首页要优先展示哪些品类?", ImageCount: 0, }, response: "开始生图前我需要确认:Logo 是否已有?主视觉偏大胆色彩还是极简?首页要优先展示哪些品类?", }) service.SetJobQueue(queue) project := design.Project{ ID: "project-clarify-before-generate", Title: "Clarify before generate", Status: design.StatusReady, UpdatedAt: time.Now(), } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{ Messages: []design.AgentChatMessage{ { Role: "user", Contents: []design.AgentContent{ {Type: "text", Text: "still day 品牌官网设计,主打 mood 配色和舒适针织。"}, }, }, }, }) if err != nil { t.Fatal(err) } if len(thread.Project.Nodes) != 0 { t.Fatalf("expected no image placeholders while clarifying, got %#v", thread.Project.Nodes) } if len(queue.jobs) != 1 || queue.jobs[0].Kind != design.JobAgentConversation { t.Fatalf("expected clarification to enqueue chat job, got %#v", queue.jobs) } if queue.jobs[0].Conversation.TaskPlan.Intent != "chat" { t.Fatalf("expected clarification plan normalized to chat, got %#v", queue.jobs[0].Conversation.TaskPlan) } } func TestCreateProjectAsyncReturnsBeforeTaskPlanning(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() planner := &recordingTaskPlanner{ fakeCreativeChatAgent: fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image"}, taskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我会拆成三张图。", ImageCount: 3, ImageTasks: []design.AgentImageTask{ {Title: "正面", Brief: "正面图"}, {Title: "侧面", Brief: "侧面图"}, {Title: "细节", Brief: "细节图"}, }, }, }, } queue := &fakeJobQueue{} service := NewDesignService(repo, nil, nil, nil, nil, planner) service.SetJobQueue(queue) project, err := service.CreateProjectAsync(ctx, "", "设计一个二次元水墨角色,输出正面、侧面、细节。", "gpt-image-2", "", true) if err != nil { t.Fatal(err) } if planner.called != 0 { t.Fatalf("expected create endpoint to return before model task planning, called planner %d times", planner.called) } if len(project.Nodes) != 0 { t.Fatalf("expected no image placeholders until async planner finishes, got %#v", project.Nodes) } if len(project.Messages) != 2 || project.Messages[1].Name != "agent_planner" || project.Messages[1].Status != "running" { t.Fatalf("expected initial async planning message, got %#v", project.Messages) } if !strings.Contains(project.Messages[1].ToolHint, "web_search_enabled") { t.Fatalf("expected project to remember home web-search handoff, got %#v", project.Messages[1]) } if len(queue.jobs) != 1 || queue.jobs[0].Kind != design.JobCreateProjectGeneration || !queue.jobs[0].EnableWebSearch || !agentTaskPlanIsEmpty(queue.jobs[0].TaskPlan) { t.Fatalf("expected empty-plan create generation job, got %#v", queue.jobs) } } func TestCreateProjectAsyncWithEmptyPromptCreatesBlankCanvas(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() queue := &fakeJobQueue{} service := NewDesignService(repo, nil, nil, nil, nil, fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image"}, }) service.SetJobQueue(queue) project, err := service.CreateProjectAsync(ctx, "", "", "gpt-image-2", "", true) if err != nil { t.Fatal(err) } if project.Brief != "" || project.Status != design.StatusReady || project.LastThreadID != "" { t.Fatalf("expected blank ready canvas, got %#v", project) } if len(project.Nodes) != 0 || len(project.Messages) != 0 { t.Fatalf("expected no default nodes or messages, got nodes=%#v messages=%#v", project.Nodes, project.Messages) } if len(queue.jobs) != 0 { t.Fatalf("expected no async generation job for blank canvas, got %#v", queue.jobs) } } func TestCreateProjectGenerationAddsPlaceholdersAfterPlannerReturns(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() creative := fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image"}, taskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我将并行生成三张角色设计图。", ImageCount: 3, ImageTasks: []design.AgentImageTask{ {Title: "水墨角色_正面", Brief: "正面全身立绘"}, {Title: "水墨角色_侧面", Brief: "侧面全身立绘"}, {Title: "水墨角色_细节", Brief: "局部细节拆解"}, }, }, } service := NewDesignService(repo, inspectingCreateProjectAgent{repo: repo, t: t, expected: 3}, nil, nil, nil, creative) service.SetJobQueue(&fakeJobQueue{}) project, err := service.CreateProjectAsync(ctx, "", "设计二次元水墨人物形象,输出角色正面、侧面、细节。", "gpt-image-2", "", false) if err != nil { t.Fatal(err) } if err := service.ProcessJob(ctx, design.Job{ Kind: design.JobCreateProjectGeneration, ProjectID: project.ID, UserID: project.UserID, ThreadID: project.LastThreadID, Prompt: project.Brief, }); err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 3 || saved.Nodes[0].Status != "success" { t.Fatalf("expected generated nodes to replace async placeholders, got %#v", saved.Nodes) } if saved.Messages[1].Status != "success" || saved.Messages[1].Content != "我将并行生成三张角色设计图。" { t.Fatalf("expected planner message to be updated, got %#v", saved.Messages[1]) } } func TestCreateProjectGenerationStreamsEachImageAsItFinishes(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() creative := fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image"}, taskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我将并行生成两张图。", ImageCount: 2, ImageTasks: []design.AgentImageTask{ {Title: "卡背设计", Brief: "卡背设计"}, {Title: "卡面信息层级", Brief: "卡面信息层级"}, }, }, } service := NewDesignService(repo, incrementalCreateProjectAgent{repo: repo, t: t}, nil, nil, nil, creative) service.SetJobQueue(&fakeJobQueue{}) project, err := service.CreateProjectAsync(ctx, "", "为桌游卡牌产品创建品牌视觉,包含卡背和卡面信息层级。", "gpt-image-2", "", false) if err != nil { t.Fatal(err) } if err := service.ProcessJob(ctx, design.Job{ Kind: design.JobCreateProjectGeneration, ProjectID: project.ID, UserID: project.UserID, ThreadID: project.LastThreadID, Prompt: project.Brief, }); err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 2 || saved.Nodes[0].Status != "success" || saved.Nodes[1].Status != "success" { t.Fatalf("expected all incremental nodes saved, got %#v", saved.Nodes) } artifactMessages := 0 for _, message := range saved.Messages { if message.Name == "generate_media" { artifactMessages++ } } if artifactMessages != 2 { t.Fatalf("expected one artifact event per generated image, got %#v", saved.Messages) } } func TestCreateProjectGenerationFinalizesAfterStaleHeartbeatOverwrite(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() creative := fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image"}, taskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我将生成一张盲盒IP视觉体系图。", ImageCount: 1, ImageTasks: []design.AgentImageTask{ {Title: "盲盒IP视觉体系", Brief: "角色、包装、展示台、贴纸和发售海报总览"}, }, }, } service := NewDesignService(repo, staleOverwriteIncrementalCreateProjectAgent{repo: repo, t: t}, nil, nil, nil, creative) service.SetJobQueue(&fakeJobQueue{}) project, err := service.CreateProjectAsync(ctx, "", "设计一张盲盒IP视觉体系。", "gpt-image-2", "", false) if err != nil { t.Fatal(err) } if err := service.ProcessJob(ctx, design.Job{ Kind: design.JobCreateProjectGeneration, ProjectID: project.ID, UserID: project.UserID, ThreadID: project.LastThreadID, Prompt: project.Brief, }); err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if saved.Status != design.StatusReady { t.Fatalf("expected project ready after stale heartbeat overwrite, got %s", saved.Status) } if len(saved.Nodes) != 1 || saved.Nodes[0].Status != "success" || saved.Nodes[0].Content != "https://example.com/blind-box-system.png" { t.Fatalf("expected final generated node restored, got %#v", saved.Nodes) } foundToolSuccess := false foundArtifact := false for _, message := range saved.Messages { if message.Title == "GPT Image 2" && message.ThreadID == project.LastThreadID && message.Status == "success" { foundToolSuccess = true } if message.Name == "generate_media" && strings.Contains(message.Content, "blind-box-system.png") { foundArtifact = true } } if !foundToolSuccess || !foundArtifact { t.Fatalf("expected final tool success and restored artifact message, got %#v", saved.Messages) } } func TestCreateProjectGenerationPartialFailurePreservesSuccessfulImagesAndClosesTask(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() creative := fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image"}, taskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我将并行生成两张图。", ImageCount: 2, ImageTasks: []design.AgentImageTask{ {Title: "方案一", Brief: "生成第一张视觉"}, {Title: "方案二", Brief: "生成第二张视觉"}, }, }, } service := NewDesignService(repo, partialFailingIncrementalCreateProjectAgent{}, nil, nil, nil, creative) service.SetJobQueue(&fakeJobQueue{}) project, err := service.CreateProjectAsync(ctx, "", "生成两张品牌视觉。", "gpt-image-2", "", false) if err != nil { t.Fatal(err) } if err := service.ProcessJob(ctx, design.Job{ Kind: design.JobCreateProjectGeneration, ProjectID: project.ID, UserID: project.UserID, ThreadID: project.LastThreadID, Prompt: project.Brief, }); err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if saved.Status != design.StatusFailed { t.Fatalf("expected partial failure to settle the project as failed, got %s", saved.Status) } if len(saved.Nodes) != 2 { t.Fatalf("expected successful image plus failed placeholder, got %#v", saved.Nodes) } if saved.Nodes[0].Status != "success" || saved.Nodes[0].Content != "https://example.com/option-1.png" { t.Fatalf("expected first image preserved, got %#v", saved.Nodes[0]) } if saved.Nodes[1].Status != "error" { t.Fatalf("expected missing image placeholder marked error, got %#v", saved.Nodes[1]) } foundToolFailure := false foundArtifact := false foundError := false for _, message := range saved.Messages { if message.Title == "GPT Image 2" && message.ThreadID == project.LastThreadID && message.Status == "failed" { foundToolFailure = true } if message.Name == "generate_media" && message.Status == "success" { foundArtifact = true } if message.Role == "error" && message.Title == "图片生成部分失败" && strings.Contains(message.Content, "已保留成功结果") { foundError = true } } if !foundToolFailure || !foundArtifact || !foundError { t.Fatalf("expected failed tool, saved artifact, and partial error message, got %#v", saved.Messages) } } func TestGetProjectClearsStaleGeneratingPlaceholders(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) now := time.Now() service.now = func() time.Time { return now } project := design.Project{ ID: "project-stale-generating", Title: "Stale generating", Status: design.StatusExploring, UpdatedAt: now.Add(-(generationBudget + time.Minute)), LastThreadID: "thread-stale", Nodes: []design.Node{ {ID: "placeholder-1", Type: design.NodeTypeImage, Title: "海报视觉 生成中", Content: "请生成3张海报", Status: "generating"}, {ID: "placeholder-2", Type: design.NodeTypeImage, Title: "海报视觉 生成中", Content: "请生成3张海报", Status: "generating"}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } saved, err := service.GetProject(ctx, project.ID) if err != nil { t.Fatal(err) } if saved.Status != design.StatusFailed { t.Fatalf("expected failed stale project, got %s", saved.Status) } if len(saved.Nodes) != 0 { t.Fatalf("expected stale placeholders removed, got %#v", saved.Nodes) } if len(saved.Messages) != 1 || saved.Messages[0].Title != "图片生成超时" { t.Fatalf("expected timeout error message, got %#v", saved.Messages) } } func TestGetProjectClearsStaleGeneratingPlaceholdersFromGenerationStepStart(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) now := time.Now() service.now = func() time.Time { return now } threadID := "thread-stale-heartbeat" project := design.Project{ ID: "project-stale-heartbeat", Title: "Stale heartbeat", Status: design.StatusExploring, UpdatedAt: now.Add(-time.Second), LastThreadID: threadID, Nodes: []design.Node{ {ID: "placeholder-1", Type: design.NodeTypeImage, Title: "海报视觉", Content: "请生成海报", Status: "generating"}, }, Messages: []design.Message{ { ID: "tool-running", Role: "tool", Type: "tool", Title: "GPT Image 2", Content: "图片还在生成中,已等待约 6m 40s。", ThreadID: threadID, Status: "running", CreatedAt: now.Add(-(generationBudget + time.Minute)), }, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } saved, err := service.GetProject(ctx, project.ID) if err != nil { t.Fatal(err) } if saved.Status != design.StatusFailed { t.Fatalf("expected failed stale project, got %s", saved.Status) } if len(saved.Nodes) != 0 { t.Fatalf("expected stale placeholders removed, got %#v", saved.Nodes) } } func TestGetProjectRecoversOrphanedAgentGeneration(t *testing.T) { ctx := design.ContextWithUserID(context.Background(), design.DefaultUserID) repo := memory.NewProjectRepository() queue := &fakeJobQueue{} service := NewDesignService(repo, nil, nil, nil, nil) service.SetJobQueue(queue) now := time.Now() service.now = func() time.Time { return now } threadID := "thread-orphaned-generation" project := design.Project{ ID: "project-orphaned-generation", UserID: design.DefaultUserID, Title: "Orphaned generation", Brief: "生成两张品牌视觉", Status: design.StatusExploring, LastThreadID: threadID, UpdatedAt: now.Add(-3 * time.Minute), Nodes: []design.Node{ {ID: "placeholder-1", Type: design.NodeTypeImage, Title: "方案一", Content: "第一张视觉", Status: "generating", Width: 1024, Height: 1024}, {ID: "placeholder-2", Type: design.NodeTypeImage, Title: "方案二", Content: "第二张视觉", Status: "generating", Width: 1024, Height: 1024}, }, Messages: []design.Message{ {ID: "user", Role: "user", Type: "user", Content: "生成两张品牌视觉", ThreadID: threadID, CreatedAt: now.Add(-4 * time.Minute)}, {ID: "tool", Role: "tool", Type: "tool", Title: "GPT Image 2", Status: "running", ThreadID: threadID, CreatedAt: now.Add(-3 * time.Minute)}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } recovered, err := service.GetProject(ctx, project.ID) if err != nil { t.Fatal(err) } if len(queue.jobs) != 1 { t.Fatalf("expected one recovery job, got %#v", queue.jobs) } job := queue.jobs[0] if job.Kind != design.JobFollowupGeneration || job.ProjectID != project.ID || job.ThreadID != threadID { t.Fatalf("unexpected recovery job: %#v", job) } if job.ImageModel != "gpt-image-2" || job.ImageSize != "1024x1024" { t.Fatalf("expected recovered image options, got %#v", job) } if job.TaskPlan.ImageCount != 2 || len(job.TaskPlan.ImageTasks) != 2 || job.TaskPlan.ImageTasks[0].Brief != "第一张视觉" { t.Fatalf("expected task plan from placeholders, got %#v", job.TaskPlan) } if recovered.Messages[len(recovered.Messages)-1].ToolHint != "generation_recovery:1" { t.Fatalf("expected recovery marker on running tool message, got %#v", recovered.Messages) } if _, err := service.GetProject(ctx, project.ID); err != nil { t.Fatal(err) } if len(queue.jobs) != 1 { t.Fatalf("expected no duplicate recovery before heartbeat window, got %#v", queue.jobs) } } func TestCreateProjectGenerationMarksWorkerCancellationFailed(t *testing.T) { ctx := design.ContextWithUserID(context.Background(), design.DefaultUserID) repo := memory.NewProjectRepository() service := NewDesignService(repo, failingAgentRunner{err: context.Canceled}, nil, nil, nil) threadID := "thread-worker-cancel" project := design.Project{ ID: "project-worker-cancel", UserID: design.DefaultUserID, Title: "Worker cancellation", Brief: "生成一张海报", Status: design.StatusExploring, LastThreadID: threadID, UpdatedAt: time.Now(), Nodes: []design.Node{ {ID: "agent-placeholder", Type: design.NodeTypeImage, Title: "海报", Content: "生成一张海报", Status: "generating"}, {ID: "image-generator", Type: design.NodeTypeImage, Title: "图像生成器", Content: "独立生成", Status: "generating", LayerRole: "image-generator"}, }, Messages: []design.Message{ {ID: "user", Role: "user", Type: "user", Content: "生成一张海报", ThreadID: threadID, CreatedAt: time.Now()}, {ID: "planner", Role: "assistant", Type: "assistant", Name: "agent_planner", Status: "success", ThreadID: threadID, CreatedAt: time.Now()}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } err := service.ProcessJob(ctx, design.Job{ Kind: design.JobCreateProjectGeneration, UserID: design.DefaultUserID, ProjectID: project.ID, ThreadID: threadID, Prompt: project.Brief, TaskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我会生成一张海报。", ImageCount: 1, ImageTasks: []design.AgentImageTask{{Title: "海报", Brief: "生成一张海报"}}, }, }) if err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 1 || saved.Nodes[0].ID != "image-generator" { t.Fatalf("expected failed agent placeholder removed without touching image generator, got %#v", saved.Nodes) } if saved.Status != design.StatusExploring { t.Fatalf("expected project to stay exploring for independent image generator, got %s", saved.Status) } last := saved.Messages[len(saved.Messages)-1] if last.Role != "error" || last.Status != "failed" || last.ThreadID != threadID { t.Fatalf("expected failed error message on original thread, got %#v", last) } } func TestCreateProjectGenerationFailureUsesJobUserContext(t *testing.T) { userID := "91cd197b-8255-4172-9981-77391fd38f33" ctx := design.ContextWithUserID(context.Background(), userID) repo := memory.NewProjectRepository() service := NewDesignService(repo, failingAgentRunner{err: errors.New("upstream image response timed out")}, nil, nil, nil) threadID := "thread-non-default-user" project := design.Project{ ID: "project-non-default-user", UserID: userID, Title: "Non default user", Brief: "生成一张海报", Status: design.StatusExploring, LastThreadID: threadID, UpdatedAt: time.Now(), Nodes: []design.Node{ {ID: "agent-placeholder", Type: design.NodeTypeImage, Title: "海报", Content: "生成一张海报", Status: "generating"}, }, Messages: []design.Message{ {ID: "user", Role: "user", Type: "user", Content: "生成一张海报", ThreadID: threadID, CreatedAt: time.Now()}, {ID: "planner", Role: "assistant", Type: "assistant", Name: "agent_planner", Status: "success", ThreadID: threadID, CreatedAt: time.Now()}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } err := service.ProcessJob(context.Background(), design.Job{ Kind: design.JobCreateProjectGeneration, UserID: userID, ProjectID: project.ID, ThreadID: threadID, Prompt: project.Brief, TaskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我会生成一张海报。", ImageCount: 1, ImageTasks: []design.AgentImageTask{{Title: "海报", Brief: "生成一张海报"}}, }, }) if err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if saved.Status != design.StatusFailed { t.Fatalf("expected failed project, got %s", saved.Status) } last := saved.Messages[len(saved.Messages)-1] if last.Role != "error" || strings.Contains(last.Content, "upstream") || !strings.Contains(last.Content, "画布内容已保留") { t.Fatalf("expected safe failure message for job user, got %#v", last) } } func TestHandleJobFailureClearsAgentGenerationPlaceholders(t *testing.T) { ctx := design.ContextWithUserID(context.Background(), design.DefaultUserID) repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) threadID := "thread-lease-expired" project := design.Project{ ID: "project-lease-expired", UserID: design.DefaultUserID, Title: "Lease expired", Status: design.StatusExploring, LastThreadID: threadID, UpdatedAt: time.Now(), Nodes: []design.Node{ {ID: "agent-placeholder", Type: design.NodeTypeImage, Title: "海报", Content: "生成一张海报", Status: "generating"}, {ID: "image-generator", Type: design.NodeTypeImage, Title: "图像生成器", Content: "独立生成", Status: "generating", LayerRole: "image-generator"}, }, Messages: []design.Message{ {ID: "tool", Role: "tool", Type: "tool", Title: "GPT Image 2", ThreadID: threadID, Status: "running", CreatedAt: time.Now()}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } err := service.HandleJobFailure(ctx, design.Job{ Kind: design.JobCreateProjectGeneration, UserID: design.DefaultUserID, ProjectID: project.ID, ThreadID: threadID, Prompt: "生成一张海报", }, errors.New("asynq: task lease expired")) if err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 1 || saved.Nodes[0].ID != "image-generator" { t.Fatalf("expected only independent image generator placeholder to remain, got %#v", saved.Nodes) } if saved.Messages[0].Status != "failed" { t.Fatalf("expected running tool message marked failed, got %#v", saved.Messages[0]) } if saved.Status != design.StatusExploring { t.Fatalf("expected project to keep exploring because independent generator remains, got %s", saved.Status) } } func TestApplyCreateProjectTaskPlanDoesNotDuplicatePlaceholderOnRetry(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) threadID := "thread-retry" project := design.Project{ ID: "project-retry-placeholder", Title: "Retry placeholder", Status: design.StatusExploring, LastThreadID: threadID, UpdatedAt: time.Now(), Nodes: []design.Node{ {ID: "placeholder-1", Type: design.NodeTypeImage, Title: "日系面包店品牌视觉系统整版", Content: "第一次规划出来的 brief", Status: "generating"}, }, Messages: []design.Message{ {ID: "planner", Role: "assistant", Type: "assistant", Name: "agent_planner", Status: "success", ThreadID: threadID, CreatedAt: time.Now()}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } err := service.applyCreateProjectTaskPlan(ctx, project.ID, threadID, "为日系面包店做品牌视觉系统", "", design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我会生成一张整版品牌视觉系统。", ImageCount: 1, ImageTasks: []design.AgentImageTask{ {Title: "日系面包店品牌VI整合页", Brief: "重试后模型给出的不同 brief"}, }, }) if err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 1 { t.Fatalf("expected retry to reuse existing generating placeholder, got %#v", saved.Nodes) } } func TestGenerationStepHeartbeatUpdatesLatestToolMessage(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) project := design.Project{ ID: "project-heartbeat", Title: "Heartbeat", Status: design.StatusExploring, LastThreadID: "thread-heartbeat", UpdatedAt: time.Now(), Messages: []design.Message{ {ID: "step-1", Role: "tool", Type: "tool", Title: "GPT Image 2", Content: "正在生成图片", ThreadID: "thread-heartbeat", Status: "running", CreatedAt: time.Now()}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } if err := service.updateLatestGenerationStep(ctx, project.ID, "thread-heartbeat", generationHeartbeatContent("生成一张图", 65*time.Second), "running"); err != nil { t.Fatal(err) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if !strings.Contains(saved.Messages[0].Content, "1m 5s") || saved.Messages[0].Status != "running" { t.Fatalf("expected heartbeat content on latest generation step, got %#v", saved.Messages[0]) } } func TestWebSearchRunsWhenEnabledEvenIfModelSkips(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() project := design.Project{ ID: "project-search", Title: "Search decision", Brief: "still day 官网首页", Status: design.StatusReady, UpdatedAt: time.Now(), Messages: []design.Message{ {ID: "m-1", Role: "user", Type: "user", Content: "保留高饱和撞色,但不要像测试图。", CreatedAt: time.Now()}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } searcher := &fakeResearcher{} agent := &fakeResearchDecisionAgent{decision: design.AgentResearchDecision{ShouldSearch: false, Reason: "canvas memory is enough"}} service := NewDesignService(repo, nil, nil, searcher, nil, agent) err := service.maybeAddResearchStep(ctx, project.ID, "thread-1", project, "优化当前首页视觉", "design", nil, true) if err != nil { t.Fatal(err) } if !searcher.called { t.Fatal("expected enabled web search to run even when model decision says no") } if searcher.query != fallbackResearchQuery("优化当前首页视觉", "design") { t.Fatalf("expected fallback search query, got %q", searcher.query) } if agent.memory.ShortTerm == "" || agent.memory.LongTerm == "" { t.Fatalf("expected short and long memory in research decision, got %#v", agent.memory) } searcher.called = false searcher.query = "" agent.decision = design.AgentResearchDecision{ShouldSearch: true, Query: "still day ecommerce homepage reference", Reason: "needs external references"} err = service.maybeAddResearchStep(ctx, project.ID, "thread-1", project, "优化当前首页视觉", "design", nil, true) if err != nil { t.Fatal(err) } if !searcher.called || searcher.query != "still day ecommerce homepage reference" { t.Fatalf("expected model-selected query, got called=%v query=%q", searcher.called, searcher.query) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Messages) == len(project.Messages) || saved.Messages[len(saved.Messages)-1].Title != "Synthesize Skills" { t.Fatalf("expected research record message, got %#v", saved.Messages) } } func TestAgentChatUsesPlannerSearchQueryInsteadOfRawPrompt(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() searcher := &fakeResearcher{} prompt := "Moteva 让设计变简单懂你的设计 Agent,帮我搞定一切设计需求,制作一个 logo" searchQuery := "AI design agent logo design case study brand identity visual identity moodboard -maker -generator -template" service := NewDesignService(repo, nil, nil, searcher, nil, fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image", Reason: "logo design"}, taskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我会先整理 logo 参考方向,再生成设计稿。", ShouldSearch: true, SearchQuery: searchQuery, SearchReason: "needs logo design references", ImageCount: 1, ImageTasks: []design.AgentImageTask{ {Title: "Logo 设计稿", Brief: "为 AI 设计 Agent 产品生成 logo 设计稿"}, }, }, }) service.SetJobQueue(&fakeJobQueue{}) project := design.Project{ ID: "project-planned-search", Title: "Planned search", Status: design.StatusReady, UpdatedAt: time.Now(), } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{ EnableWebSearch: true, Messages: []design.AgentChatMessage{ { Role: "user", Contents: []design.AgentContent{ {Type: "text", Text: prompt}, }, }, }, }) if err != nil { t.Fatal(err) } if !searcher.called { t.Fatal("expected planned web search to run") } if searcher.query != searchQuery { t.Fatalf("expected planner-selected search query, got %q", searcher.query) } if searcher.query == prompt { t.Fatal("expected search query to not be the raw user prompt") } foundResearch := false for _, message := range thread.Project.Messages { if message.Title == "Synthesize Skills" && strings.Contains(message.Content, searchQuery) { foundResearch = true break } } if !foundResearch { t.Fatalf("expected research message with planned query, got %#v", thread.Project.Messages) } } func TestInitialWebSearchFallsBackToSearchQueryWhenModelSkips(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() threadID := "thread-initial-search" now := time.Now() project := design.Project{ ID: "project-initial-search", Title: "Initial search", Status: design.StatusExploring, UpdatedAt: now, Messages: []design.Message{ {ID: "user", Role: "user", Type: "user", Content: "做一个美漫风格分镜", ThreadID: threadID, CreatedAt: now}, {ID: "planner", Role: "assistant", Type: "assistant", Name: "agent_planner", ToolHint: "agent_planner:web_search_enabled", Status: "running", ThreadID: threadID, CreatedAt: now.Add(time.Millisecond)}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } searcher := &fakeResearcher{} agent := &fakeResearchDecisionAgent{decision: design.AgentResearchDecision{ShouldSearch: false, Reason: "model skipped"}} service := NewDesignService(repo, nil, nil, searcher, nil, agent) err := service.maybeAddResearchStep(ctx, project.ID, threadID, project, "做一个美漫风格分镜", "design", nil, true) if err != nil { t.Fatal(err) } if !searcher.called { t.Fatal("expected enabled initial web search to run") } if searcher.query != fallbackResearchQuery("做一个美漫风格分镜", "design") { t.Fatalf("expected fallback search query, got %q", searcher.query) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Messages) == len(project.Messages) || saved.Messages[len(saved.Messages)-1].Title != "Synthesize Skills" { t.Fatalf("expected research message, got %#v", saved.Messages) } } func TestStopAgentTaskClearsAgentPlaceholdersWithoutStoppingImageGenerator(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() service := NewDesignService(repo, nil, nil, nil, nil) project := design.Project{ ID: "project-stop-agent", Title: "Stop agent", Brief: "生成三张品牌视觉", Status: design.StatusExploring, LastThreadID: "thread-agent", UpdatedAt: time.Now(), Nodes: []design.Node{ {ID: "agent-placeholder-1", Type: design.NodeTypeImage, Title: "方案一", Content: "生成三张品牌视觉", Status: "generating"}, {ID: "agent-placeholder-2", Type: design.NodeTypeImage, Title: "方案二", Content: "生成三张品牌视觉", Status: "generating"}, {ID: "image-generator", Type: design.NodeTypeImage, Title: "图像生成器", Content: "独立生成", Status: "generating", LayerRole: "image-generator"}, }, Messages: []design.Message{ {ID: "user-1", Role: "user", Type: "user", Content: "生成三张品牌视觉", ThreadID: "thread-agent", CreatedAt: time.Now()}, {ID: "tool-1", Role: "tool", Type: "tool", Title: "GPT Image 2", Status: "running", ThreadID: "thread-agent", CreatedAt: time.Now()}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } saved, err := service.StopAgentTask(ctx, project.ID, "thread-agent") if err != nil { t.Fatal(err) } if len(saved.Nodes) != 1 || saved.Nodes[0].ID != "image-generator" { t.Fatalf("expected only independent image generator placeholder to remain, got %#v", saved.Nodes) } if saved.Status != design.StatusExploring { t.Fatalf("expected project to stay exploring for independent image generator, got %s", saved.Status) } if len(saved.Messages) != 3 || saved.Messages[1].Status != "cancelled" || saved.Messages[2].Status != "cancelled" { t.Fatalf("expected cancelled assistant message, got %#v", saved.Messages) } if !service.isAgentTaskCancelled(project.ID, "thread-agent") { t.Fatal("expected running task cancellation to be recorded") } } func TestAgentChatContinuesStoppedThreadWithNewTask(t *testing.T) { ctx := context.Background() repo := memory.NewProjectRepository() queue := &fakeJobQueue{} service := NewDesignService(repo, nil, nil, nil, nil, fakeCreativeChatAgent{ decision: design.AgentDecision{Intent: "image"}, taskPlan: design.AgentTaskPlan{ Intent: "image", UserFacingResponse: "我会生成图片。", ImageCount: 1, ImageTasks: []design.AgentImageTask{{Title: "视觉方案", Brief: "视觉方案"}}, }, }) service.SetJobQueue(queue) project := design.Project{ ID: "project-cancelled-thread", Title: "Cancelled", Status: design.StatusReady, LastThreadID: "thread-cancelled", UpdatedAt: time.Now(), Messages: []design.Message{ {ID: "stop-1", Role: "assistant", Type: "assistant", Content: "已停止", ThreadID: "thread-cancelled", Status: "cancelled", CreatedAt: time.Now()}, }, } if err := repo.Save(ctx, project); err != nil { t.Fatal(err) } service.cancelAgentTask(project.ID, "thread-cancelled") thread, err := service.AgentChat(ctx, project.ID, design.AgentChatRequest{ ThreadID: "thread-cancelled", Messages: []design.AgentChatMessage{ { Role: "user", Contents: []design.AgentContent{ {Type: "text", Text: "生成一张海报"}, }, }, }, }) if err != nil { t.Fatal(err) } if thread.Status != "running" { t.Fatalf("expected stopped thread to accept a new running task, got %s", thread.Status) } saved, err := repo.Get(ctx, project.ID) if err != nil { t.Fatal(err) } if len(saved.Nodes) != 1 || saved.Nodes[0].Status != "generating" { t.Fatalf("expected new image task placeholder, got %#v", saved.Nodes) } if len(saved.Messages) <= 1 || saved.Messages[len(saved.Messages)-1].Status != "success" { t.Fatalf("expected new thread messages after cancelled task, got %#v", saved.Messages) } if len(queue.jobs) != 1 || queue.jobs[0].Kind != design.JobFollowupGeneration { t.Fatalf("expected new followup generation job, got %#v", queue.jobs) } }