package brief import ( "context" "strings" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/project" ) func newReq(phase project.Phase) *project.Requirement { return &project.Requirement{ ID: uuid.New(), Number: 7, Title: "导出报表", Description: "支持导出 Excel", Phase: phase, } } func newIssue() *project.Issue { return &project.Issue{ ID: uuid.New(), Number: 12, Title: "修复导出乱码", Description: "UTF-8 BOM 缺失", } } func art(phase project.Phase, version int, content string) *project.Artifact { return &project.Artifact{ ID: uuid.New(), RequirementID: uuid.New(), Phase: phase, Version: version, Content: content, } } func TestComposeTaskBrief_FullBriefAllSectionsInPriorityOrder(t *testing.T) { t.Parallel() c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhaseAuditing), Artifacts: []*project.Artifact{ art(project.PhasePlanning, 1, "# 规划\n## 验收标准\n- 必须支持 Excel"), art(project.PhasePrototyping, 1, "# 原型内容"), art(project.PhaseAuditing, 1, "# 评审内容"), }, UserPrompt: "请开始实现", Kind: KindACPSession, }) require.NoError(t, err) assert.False(t, res.Truncated) // 角色(auditing 阶段用阶段角色)+ 交互协议 + 需求核心 + 验收标准 + 三个产物 + 用户指令。 wantOrder := []string{ "role", "interactive_protocol", "requirement_core", "acceptance_criteria", "artifact_auditing", "artifact_prototyping", "artifact_planning", "user_prompt", } assert.Equal(t, wantOrder, res.Sections) // 验收标准必须出现在 auditing 产物之前(优先级)。 idxAC := strings.Index(res.Text, "验收标准") idxAudit := strings.Index(res.Text, "评审内容") require.GreaterOrEqual(t, idxAC, 0) require.GreaterOrEqual(t, idxAudit, 0) assert.Less(t, idxAC, idxAudit) // 用户指令永远在最后。 assert.True(t, strings.HasSuffix(strings.TrimSpace(res.Text), "请开始实现")) // 角色提示与需求标题都在。 assert.Contains(t, res.Text, PhaseRolePrompts[project.PhaseAuditing]) assert.Contains(t, res.Text, "需求 #7:导出报表") } func TestComposeTaskBrief_TightBudgetDropsLowPrioritySections(t *testing.T) { t.Parallel() c := NewComposer(nil, Config{}) bigPlanning := strings.Repeat("规", 4000) // ~1000 tokens res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhasePlanning), Artifacts: []*project.Artifact{ art(project.PhasePlanning, 1, bigPlanning), art(project.PhaseAuditing, 1, strings.Repeat("评", 4000)), }, UserPrompt: "做", Kind: KindACPSession, TokenBudget: 300, // 紧预算 }) require.NoError(t, err) assert.True(t, res.Truncated) // 高优先级(角色/需求核心)保留,用户指令永远保留。 assert.Contains(t, res.Sections, "role") assert.Contains(t, res.Sections, "requirement_core") assert.Contains(t, res.Sections, "user_prompt") // 用户指令永不丢。 assert.Contains(t, res.Text, "## 操作者指令\n做") } func TestComposeTaskBrief_RequirementOnly_IssueOnly_Both(t *testing.T) { t.Parallel() c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) // requirement-only res1, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhasePlanning), Kind: KindChatRequirement, }) require.NoError(t, err) assert.Contains(t, res1.Sections, "requirement_core") assert.NotContains(t, res1.Sections, "issue_core") // issue-only res2, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Issue: newIssue(), Kind: KindChatIssue, }) require.NoError(t, err) assert.Contains(t, res2.Sections, "issue_core") assert.NotContains(t, res2.Sections, "requirement_core") assert.Contains(t, res2.Text, "Issue #12:修复导出乱码") // both res3, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhasePlanning), Issue: newIssue(), Kind: KindACPSession, }) require.NoError(t, err) assert.Contains(t, res3.Sections, "requirement_core") assert.Contains(t, res3.Sections, "issue_core") } func TestComposeTaskBrief_MissingDataYieldsNoErrorAndOmitsSections(t *testing.T) { t.Parallel() c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) // 空描述 + 无产物。 req := newReq(project.PhasePlanning) req.Description = "" res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: req, Kind: KindChatRequirement, }) require.NoError(t, err) assert.NotContains(t, res.Text, "需求描述") assert.NotContains(t, res.Sections, "artifact_planning") assert.NotContains(t, res.Sections, "acceptance_criteria") // 完全空输入(无 PM 上下文,仅 ACP 角色)。 resEmpty, err := c.ComposeTaskBrief(context.Background(), BriefInput{Kind: KindACPSession}) require.NoError(t, err) // ACP 场景仍给角色 + 交互协议,但无 PM section。 assert.Contains(t, resEmpty.Sections, "role") assert.NotContains(t, resEmpty.Sections, "requirement_core") } func TestComposeTaskBrief_AcceptanceCriteriaExtraction(t *testing.T) { t.Parallel() c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) planning := art(project.PhasePlanning, 3, `# 规划文档 ## 背景与目标 做导出。 ## 验收标准 - 能导出 Excel - 中文不乱码 ## 风险 无`) res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhasePlanning), Artifacts: []*project.Artifact{planning}, Kind: KindChatRequirement, }) require.NoError(t, err) assert.Contains(t, res.Sections, "acceptance_criteria") assert.Contains(t, res.Text, "能导出 Excel") assert.Contains(t, res.Text, "中文不乱码") // 验收标准段落不应吞掉后面的"风险"小节。 acStart := strings.Index(res.Text, "## 验收标准") require.GreaterOrEqual(t, acStart, 0) } func TestComposeTaskBrief_AcceptanceCriteriaFallbackOmitsWhenAbsent(t *testing.T) { t.Parallel() c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) planning := art(project.PhasePlanning, 1, "# 规划\n## 背景\n仅有背景,没有验收标准小节") res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhasePlanning), Artifacts: []*project.Artifact{planning}, Kind: KindChatRequirement, }) require.NoError(t, err) assert.NotContains(t, res.Sections, "acceptance_criteria") } func TestComposeTaskBrief_UserPromptNeverTruncatedEvenWhenExceedsBudget(t *testing.T) { t.Parallel() c := NewComposer(nil, Config{}) huge := strings.Repeat("做", 5000) // 远超预算 res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhasePlanning), UserPrompt: huge, Kind: KindACPSession, TokenBudget: 50, }) require.NoError(t, err) assert.Contains(t, res.Sections, "user_prompt") // 用户指令整段保留(不含截断标记)。 assert.Contains(t, res.Text, huge) assert.NotContains(t, res.Text, "操作者指令\n"+huge+truncateMarker) } func TestComposeTaskBrief_LatestArtifactPerPhase(t *testing.T) { t.Parallel() c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) // 同阶段多版本,应取 version 最大的。 res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhasePrototyping), Artifacts: []*project.Artifact{ art(project.PhasePrototyping, 1, "旧原型 v1"), art(project.PhasePrototyping, 3, "新原型 v3"), art(project.PhasePrototyping, 2, "中原型 v2"), }, Kind: KindChatRequirement, }) require.NoError(t, err) assert.Contains(t, res.Text, "新原型 v3") assert.NotContains(t, res.Text, "旧原型 v1") assert.Contains(t, res.Text, "prototyping 阶段产物 v3") } // fakeOrienter 用于验证 ACP 场景纳入仓库定向 section。 type fakeOrienter struct{ ori RepoOrientation } func (f fakeOrienter) Orient(_ context.Context, _, _ string, _, _ int) RepoOrientation { return f.ori } func TestComposeTaskBrief_RepoOrientationIncludedForACP(t *testing.T) { t.Parallel() c := NewComposer(fakeOrienter{ori: RepoOrientation{ Branch: "feat/x", RecentCommits: []string{"abc1234 init"}, DirtyFiles: []string{"main.go"}, }}, Config{DefaultTokenBudget: 100000}) res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhasePlanning), RepoCwd: "/tmp/repo", Branch: "feat/x", Kind: KindACPSession, }) require.NoError(t, err) assert.Contains(t, res.Sections, "repo_orientation") assert.Contains(t, res.Text, "当前分支:feat/x") assert.Contains(t, res.Text, "abc1234 init") assert.Contains(t, res.Text, "main.go") } func TestComposeTaskBrief_NoOrientationWhenCwdEmpty(t *testing.T) { t.Parallel() c := NewComposer(fakeOrienter{ori: RepoOrientation{Branch: "x"}}, Config{DefaultTokenBudget: 100000}) res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ Requirement: newReq(project.PhasePlanning), Kind: KindACPSession, // RepoCwd 为空 → 跳过定向 }) require.NoError(t, err) assert.NotContains(t, res.Sections, "repo_orientation") } func TestExtractSection_NumberedHeadingAndCaseInsensitive(t *testing.T) { t.Parallel() md := "## 6. Acceptance Criteria\n- foo\n## next\nbar" got := extractSection(md, acceptanceCriteriaHeadings) assert.Equal(t, "- foo", got) }