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