Files
moteva/server/internal/application/generation_failure_test.go
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
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>
2026-07-07 23:15:37 +08:00

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)
}
}