Flag-on CreateAgentCandidate asks the product PI runner for change_report_narrative_v1 after the real digest is known; timeout and malformed payloads stay full fallback. Default flag remains off. Co-authored-by: Cursor <cursoragent@cursor.com>
264 lines
9.4 KiB
Go
264 lines
9.4 KiB
Go
package docrevision_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"chat-agent-system/internal/docrevision"
|
|
)
|
|
|
|
func TestMaterializeAgentOutput_FirstGenerationProducesVisibleFallbackReport(t *testing.T) {
|
|
svc := docrevision.NewService(nil)
|
|
ctx := context.Background()
|
|
docID := "doc_first_gen"
|
|
out := "# 系统需求\n\n## 范围\n\n首轮小队交付正文\n"
|
|
|
|
res, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
|
|
DocID: docID, RunID: "run-first-1", OutputMarkdown: out,
|
|
ProjectID: 1, WorkDir: "ws/first",
|
|
ActorKind: docrevision.ActorAgentSquad,
|
|
ActorSnapshot: docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: "test"},
|
|
AutoCommitUserID: 42,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("materialize: %v", err)
|
|
}
|
|
if !res.FirstGeneration {
|
|
t.Fatal("expected FirstGeneration")
|
|
}
|
|
if res.Candidate == nil || res.Report == nil {
|
|
t.Fatalf("expected candidate+report, got %+v", res)
|
|
}
|
|
if res.Report.AnalysisStatus != docrevision.AnalysisFallback {
|
|
t.Fatalf("analysis_status want fallback got %q (must not claim full PI)", res.Report.AnalysisStatus)
|
|
}
|
|
if len(res.Report.Summary) == 0 {
|
|
t.Fatal("summary must be visible on first generation")
|
|
}
|
|
joined := strings.Join(res.Report.Summary, "\n")
|
|
if strings.Contains(joined, "智能体交付物已生成") {
|
|
t.Fatalf("must not use hardcoded fiction summary: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "确定性差异") && !strings.Contains(joined, "insert") && !strings.Contains(joined, "Insert") {
|
|
// deterministic builder uses 确定性差异 + hunk kinds
|
|
if !strings.Contains(joined, "块级变更") {
|
|
t.Fatalf("summary should reflect real diff, got %q", joined)
|
|
}
|
|
}
|
|
if len(res.Report.OpenQuestions) == 0 {
|
|
t.Fatal("open_questions should mark PI narrative as pending")
|
|
}
|
|
if res.State == nil || res.State.CurrentVersionNo < 1 {
|
|
t.Fatalf("expected committed/open state after auto-commit: %+v", res.State)
|
|
}
|
|
versions, err := svc.ListVersions(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var withReport int
|
|
for _, v := range versions {
|
|
if v.ChangeReportID != nil && *v.ChangeReportID > 0 {
|
|
withReport++
|
|
}
|
|
}
|
|
if withReport < 1 {
|
|
t.Fatalf("first squad materialize must leave a version with change_report_id for UI entry, got %+v", versions)
|
|
}
|
|
var foundV2 bool
|
|
for _, v := range versions {
|
|
if v.VersionNo == 2 && v.ChangeReportID != nil && *v.ChangeReportID > 0 {
|
|
foundV2 = true
|
|
break
|
|
}
|
|
}
|
|
if !foundV2 {
|
|
t.Fatalf("first successful materialize must produce V2 with change_report_id (not only CurrentVersionNo>=1), got %+v", versions)
|
|
}
|
|
if res.State.CurrentVersionNo != 2 {
|
|
t.Fatalf("after first auto-commit want CurrentVersionNo=2, got %+v", res.State)
|
|
}
|
|
|
|
// Second run against head: still produces candidate+report (not OpenTemplate-only).
|
|
out2 := out + "\n## 变更\n\n二轮补充\n"
|
|
res2, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
|
|
DocID: docID, RunID: "run-second-1", OutputMarkdown: out2,
|
|
ProjectID: 1, WorkDir: "ws/first",
|
|
ActorKind: docrevision.ActorAgentSquad,
|
|
ActorSnapshot: docrevision.ActorSnapshot{SquadName: "bizdev-squad"},
|
|
AutoCommitUserID: 42,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("second materialize: %v", err)
|
|
}
|
|
if res2.FirstGeneration {
|
|
t.Fatal("second run must not be FirstGeneration")
|
|
}
|
|
if res2.Candidate == nil || res2.Report == nil || len(res2.Report.Summary) == 0 {
|
|
t.Fatalf("second run must still yield visible report: %+v", res2)
|
|
}
|
|
if res2.Report.AnalysisStatus != docrevision.AnalysisFallback {
|
|
t.Fatalf("second analysis_status want fallback got %q", res2.Report.AnalysisStatus)
|
|
}
|
|
versions2, err := svc.ListVersions(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withReport = 0
|
|
for _, v := range versions2 {
|
|
if v.ChangeReportID != nil && *v.ChangeReportID > 0 {
|
|
withReport++
|
|
}
|
|
}
|
|
if withReport < 2 {
|
|
t.Fatalf("after two squad runs expect >=2 versions with change_report_id, got %d in %+v", withReport, versions2)
|
|
}
|
|
}
|
|
|
|
func TestRevisionState_ExposesPendingNeedsConfirmationCandidate(t *testing.T) {
|
|
svc := docrevision.NewService(nil)
|
|
ctx := context.Background()
|
|
docID := "doc_pending_high"
|
|
st, err := svc.OpenTemplateVersion(ctx, docID, "# base\n\n法规引用 GB9706\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
|
|
DocID: docID, RunID: "run-high-2", InputSnapshotID: st.HeadSnapshotID,
|
|
OutputMarkdown: "# base\n\n", // deletes 法规 → rule floor high
|
|
ReportJSON: "",
|
|
ActorKind: docrevision.ActorAgentSquad,
|
|
})
|
|
if err != nil && cand == nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cand.Status != docrevision.CandidateNeedsConfirmation {
|
|
t.Fatalf("want needs_confirmation got %s risk=%v", cand.Status, rep.RiskLevel)
|
|
}
|
|
got, err := svc.GetRevisionState(ctx, docID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.PendingCandidateID == nil || *got.PendingCandidateID != cand.ID {
|
|
t.Fatalf("revision-state must expose pending candidate id, got %+v", got)
|
|
}
|
|
if got.PendingChangeReportID == nil || cand.ChangeReportID == nil || *got.PendingChangeReportID != *cand.ChangeReportID {
|
|
t.Fatalf("pending change_report_id mismatch: %+v cand=%+v", got, cand)
|
|
}
|
|
if got.PendingRiskLevel != docrevision.RiskHigh && got.PendingRiskLevel != docrevision.RiskCritical {
|
|
t.Fatalf("pending risk want high/critical got %q", got.PendingRiskLevel)
|
|
}
|
|
if got.PendingCandidateStatus != docrevision.CandidateNeedsConfirmation {
|
|
t.Fatalf("pending status want needs_confirmation got %q", got.PendingCandidateStatus)
|
|
}
|
|
if got.CurrentVersionNo != 1 {
|
|
t.Fatalf("pending must not advance version, got V%d", got.CurrentVersionNo)
|
|
}
|
|
versions, _ := svc.ListVersions(ctx, docID)
|
|
for _, v := range versions {
|
|
if v.ChangeReportID != nil && cand.ChangeReportID != nil && *v.ChangeReportID == *cand.ChangeReportID {
|
|
t.Fatal("needs_confirmation report must not appear as committed version yet")
|
|
}
|
|
}
|
|
st2, err := svc.CommitAgentVersion(ctx, docID, cand.ID, docrevision.CommitAgentDecision{
|
|
Decision: "commit", Confirmed: true, ActorUserID: 1, ExpectedInputSHA: st.HeadSHA256,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st2.CurrentVersionNo != 2 {
|
|
t.Fatalf("after confirm want V2 got %+v", st2)
|
|
}
|
|
if st2.PendingCandidateID != nil {
|
|
t.Fatalf("pending must clear after commit, got %+v", st2)
|
|
}
|
|
}
|
|
|
|
func TestMaterializeAgentOutput_EmptyOutputRejected(t *testing.T) {
|
|
svc := docrevision.NewService(nil)
|
|
_, err := svc.MaterializeAgentOutput(context.Background(), docrevision.AgentMaterializeRequest{
|
|
DocID: "doc_empty", OutputMarkdown: " \n",
|
|
})
|
|
if err != docrevision.ErrNeedsContent {
|
|
t.Fatalf("want ErrNeedsContent got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMaterializeAgentOutput_InvalidReportDoesNotAutoCommit(t *testing.T) {
|
|
svc := docrevision.NewService(nil)
|
|
ctx := context.Background()
|
|
docID := "doc_invalid_report"
|
|
out := "# V\n\nbody after agent\n"
|
|
|
|
res, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
|
|
DocID: docID, RunID: "run-bad-report", OutputMarkdown: out,
|
|
ReportJSON: `{"schema_version":99,"summary":["x"]}`,
|
|
ProjectID: 1,
|
|
ActorKind: docrevision.ActorAgentSquad,
|
|
AutoCommitUserID: 7,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid report")
|
|
}
|
|
if !errors.Is(err, docrevision.ErrReportInvalid) {
|
|
t.Fatalf("want ErrReportInvalid, got %v", err)
|
|
}
|
|
if res == nil || res.Candidate == nil {
|
|
t.Fatalf("candidate should still be returned for visibility, got %+v", res)
|
|
}
|
|
if res.Candidate.Status == docrevision.CandidateCommitted {
|
|
t.Fatal("invalid/unassessed report must not auto-commit")
|
|
}
|
|
if res.Candidate.Status != docrevision.CandidateNeedsConfirmation {
|
|
t.Fatalf("status want needs_confirmation got %q", res.Candidate.Status)
|
|
}
|
|
if res.Report != nil && res.Report.AnalysisStatus != docrevision.AnalysisUnassessed {
|
|
t.Fatalf("analysis want unassessed got %q", res.Report.AnalysisStatus)
|
|
}
|
|
st, stErr := svc.GetRevisionState(ctx, docID)
|
|
if stErr != nil {
|
|
t.Fatal(stErr)
|
|
}
|
|
// Still on template/open baseline version — no agent version sealed in.
|
|
if st.CurrentVersionNo != 1 || st.HeadRevisionNo != 0 {
|
|
t.Fatalf("head must remain pre-commit baseline, got %+v", st)
|
|
}
|
|
}
|
|
|
|
func TestMaterializeAgentOutput_FlagOnFetcherNarrativeComplete(t *testing.T) {
|
|
docrevision.SetPIChangeReportNarrativeForTest(true)
|
|
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
|
|
|
|
svc := docrevision.NewService(nil)
|
|
svc.NarrativeFetcher = func(_ context.Context, spec docrevision.NarrativeFetchSpec) (string, error) {
|
|
b, _ := json.Marshal(map[string]any{
|
|
"schema_version": 1,
|
|
"schema_kind": docrevision.NarrativeSchemaKind,
|
|
"input_digest": spec.InputDigest,
|
|
"from_sha": spec.FromSHA,
|
|
"to_sha": spec.ToSHA,
|
|
"summary": []string{"PI 叙事:电气安全需复核"},
|
|
})
|
|
return string(b), nil
|
|
}
|
|
ctx := context.Background()
|
|
res, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
|
|
DocID: "doc_narr_live", RunID: "run-narr", OutputMarkdown: "# 需求\n\n新增电气安全条款\n",
|
|
ProjectID: 1, ActorKind: docrevision.ActorAgent,
|
|
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: "pi"},
|
|
AutoCommitUserID: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("materialize: %v", err)
|
|
}
|
|
if res.Report == nil || res.Report.AnalysisStatus != docrevision.AnalysisComplete {
|
|
t.Fatalf("want complete PI narrative, got %+v", res.Report)
|
|
}
|
|
joined := strings.Join(res.Report.Summary, "\n")
|
|
if !strings.Contains(joined, "PI 叙事") {
|
|
t.Fatalf("summary missing PI prose: %q", joined)
|
|
}
|
|
}
|