Files
agentic-coding-workflow/internal/acp/session_brief_internal_test.go
T
2026-06-22 08:55:57 +08:00

149 lines
5.0 KiB
Go

package acp
import (
"context"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/brief"
"github.com/yan1h/agent-coding-workflow/internal/project"
)
// recordingAudit 捕获 Record 调用,供断言 brief_composed 是否写入。
type recordingAudit struct {
mu sync.Mutex
entries []audit.Entry
}
func (r *recordingAudit) Record(_ context.Context, e audit.Entry) error {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, e)
return nil
}
func (r *recordingAudit) actions() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, 0, len(r.entries))
for _, e := range r.entries {
out = append(out, e.Action)
}
return out
}
// fakeComposer 是 brief.Composer 的测试替身:返回固定文本并记录是否被调用。
type fakeComposer struct {
res brief.BriefResult
err error
called bool
gotIn brief.BriefInput
}
func (f *fakeComposer) ComposeTaskBrief(_ context.Context, in brief.BriefInput) (brief.BriefResult, error) {
f.called = true
f.gotIn = in
return f.res, f.err
}
// fakeArtifactAccess 是 ArtifactAccess 的测试替身。
type fakeArtifactAccess struct {
arts []*project.Artifact
}
func (f fakeArtifactAccess) ListLatestArtifacts(_ context.Context, _ uuid.UUID) ([]*project.Artifact, error) {
return f.arts, nil
}
func testSession() *Session {
return &Session{
ID: uuid.New(),
ProjectID: uuid.New(),
Branch: "req/demo-7",
CwdPath: "/tmp/wt",
}
}
func TestComposeInitialPrompt_RequirementContextComposed(t *testing.T) {
t.Parallel()
rec := &recordingAudit{}
fc := &fakeComposer{res: brief.BriefResult{
Text: "你是一名资深产品规划助手。\n\n## 当前需求\n需求 #7:导出报表",
Tokens: 20,
Sections: []string{"role", "requirement_core"},
}}
s := &sessionService{
audit: rec,
composer: fc,
aa: fakeArtifactAccess{arts: []*project.Artifact{{Phase: project.PhasePlanning, Version: 2}}},
cfg: SessionServiceConfig{BriefTokenBudget: 6000},
}
req := &project.Requirement{ID: uuid.New(), Number: 7, Title: "导出报表", Phase: project.PhasePlanning}
c := Caller{UserID: uuid.New()}
out := s.composeInitialPrompt(context.Background(), c, testSession(), nil, req, nil, "请开始")
require.True(t, fc.called)
assert.Contains(t, out, "需求 #7:导出报表")
assert.Contains(t, out, "资深产品规划助手")
// 组装入参带上了 requirement + artifacts + cwd + 用户文本 + budget。
assert.Equal(t, req, fc.gotIn.Requirement)
assert.Equal(t, "请开始", fc.gotIn.UserPrompt)
assert.Equal(t, "/tmp/wt", fc.gotIn.RepoCwd)
assert.Equal(t, 6000, fc.gotIn.TokenBudget)
assert.Equal(t, brief.KindACPSession, fc.gotIn.Kind)
assert.Len(t, fc.gotIn.Artifacts, 1)
// 写了 brief_composed audit。
assert.Contains(t, rec.actions(), "acp.session.brief_composed")
}
func TestComposeInitialPrompt_IssueContextComposed(t *testing.T) {
t.Parallel()
rec := &recordingAudit{}
fc := &fakeComposer{res: brief.BriefResult{Text: "## 当前 Issue\nIssue #3:bug", Tokens: 8}}
s := &sessionService{audit: rec, composer: fc}
iss := &project.Issue{ID: uuid.New(), Number: 3, Title: "bug"}
out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, nil, iss, "fix it")
require.True(t, fc.called)
assert.Contains(t, out, "Issue #3:bug")
assert.Equal(t, iss, fc.gotIn.Issue)
// issue-only 时不调用 ArtifactAccess(aa 为 nil 也不 panic)。
}
func TestComposeInitialPrompt_NoPMContextFallsBackToRaw(t *testing.T) {
t.Parallel()
fc := &fakeComposer{res: brief.BriefResult{Text: "SHOULD NOT BE USED"}}
s := &sessionService{audit: &recordingAudit{}, composer: fc}
out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, nil, nil, "raw text only")
assert.Equal(t, "raw text only", out)
assert.False(t, fc.called, "composer must not be called without PM context")
}
func TestComposeInitialPrompt_NilComposerFallsBackToRaw(t *testing.T) {
t.Parallel()
s := &sessionService{audit: &recordingAudit{}, composer: nil}
req := &project.Requirement{ID: uuid.New(), Number: 7, Title: "x", Phase: project.PhasePlanning}
out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, req, nil, "raw")
assert.Equal(t, "raw", out)
}
func TestComposeInitialPrompt_EmptyComposedTextFallsBackToRaw(t *testing.T) {
t.Parallel()
rec := &recordingAudit{}
fc := &fakeComposer{res: brief.BriefResult{Text: ""}}
s := &sessionService{audit: rec, composer: fc}
req := &project.Requirement{ID: uuid.New(), Number: 1, Title: "t", Phase: project.PhasePlanning}
out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, req, nil, "fallback raw")
assert.Equal(t, "fallback raw", out)
// 空简报不写 brief_composed audit。
assert.NotContains(t, rec.actions(), "acp.session.brief_composed")
}