You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user