44406b72db
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1397 lines
49 KiB
Go
1397 lines
49 KiB
Go
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 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 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 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 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, "image 2") {
|
|
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" || last.Content != "upstream image response timed out" {
|
|
t.Fatalf("expected original 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 TestWebSearchRunsOnlyWhenModelDecisionNeedsIt(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 web search to stay off when model decision says no")
|
|
}
|
|
if agent.memory.ShortTerm == "" || agent.memory.LongTerm == "" {
|
|
t.Fatalf("expected short and long memory in research decision, got %#v", agent.memory)
|
|
}
|
|
|
|
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 TestInitialWebSearchDoesNotFallbackToRawPromptWhenModelSkips(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.Fatalf("expected no raw-prompt fallback search, got query=%q", searcher.query)
|
|
}
|
|
saved, err := repo.Get(ctx, project.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(saved.Messages) != len(project.Messages) {
|
|
t.Fatalf("expected no 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 TestAgentChatDoesNotRestartAlreadyCancelledThread(t *testing.T) {
|
|
ctx := context.Background()
|
|
repo := memory.NewProjectRepository()
|
|
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: "视觉方案"}},
|
|
},
|
|
})
|
|
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)
|
|
}
|
|
|
|
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 != "cancelled" {
|
|
t.Fatalf("expected cancelled thread response, got %s", thread.Status)
|
|
}
|
|
saved, err := repo.Get(ctx, project.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(saved.Nodes) != 0 || len(saved.Messages) != 1 {
|
|
t.Fatalf("expected cancelled thread not to create new work, got nodes=%#v messages=%#v", saved.Nodes, saved.Messages)
|
|
}
|
|
}
|