Files
Agent-System/internal/server/workflow_governance_piagent_pg_test.go
T
weichen d6f5e0e28a feat(projectcontext): bizdev 注入项目对话并完成 PG 治理 E2E
新增 ctxmgr 项目对话采集并接入 PM/TE BuildSnapshot;PG 测试统一 TEST_DATABASE_URL 并使用三正式工作流 key。
2026-09-13 02:50:23 +00:00

273 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/projectinstance"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/server"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/workflowdef"
)
func piAgentRunnerHealthy() bool {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get("http://127.0.0.1:18865/health")
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
func setupPGPiAgentServer(t *testing.T, pool *pgxpool.Pool) (http.Handler, *server.Server) {
t.Helper()
cfg := configFromTestDatabaseURL(t)
cfg.AgentBackend = "pi-agent"
cfg.PiAgentRunnerURL = "http://127.0.0.1:18865"
cfg.PiAgentModel = "MiniMax-M3"
cfg.PiAgentProvider = "minimax"
svc := bizdev.MustNewService(t, cfg)
if svc.Pool() == nil {
t.Skip("bizdev service not connected to postgres")
}
srv := server.NewServer(cfg, svc)
return srv.Routes(), srv
}
// TestWorkflowGovernance_PiAgent_PG_FullChain
// PG 治理链 + pi-agent 真实 Agent:activate → pi-agent intake → approve → advance
//
// go test ./internal/server/ -run TestWorkflowGovernance_PiAgent_PG -timeout 1h -v
func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow pi-agent PG E2E in short mode")
}
if !piAgentRunnerHealthy() {
t.Skip("pi-agent runner not available at http://127.0.0.1:18865/health")
}
pool := openPGPoolWithMigrations(t)
if pool == nil {
return
}
defer pool.Close()
ctx := context.Background()
handler, srv := setupPGPiAgentServer(t, pool)
ts := httptest.NewServer(handler)
defer ts.Close()
client := &http.Client{Timeout: 50 * time.Minute}
wfSvc := workflowdef.NewService(pool, nil)
wfKey := "pg_pi_p5_" + time.Now().Format("150405.000")
_, ver, err := wfSvc.CreateDefinition(ctx, workflowdef.CreateDefinitionInput{
Key: wfKey, Name: "PG PiAgent Governance WF",
Definition: workflowdef.VersionDefinition{
SchemaVersion: 1,
Stages: []workflowdef.StageDef{{
Key: "biz_intake", Name: "Intake", Position: 0,
DocTasks: []workflowdef.DocTaskDef{{
Key: "intake", Required: true, Blocking: true, DefaultEnabled: true,
OutputDocType: "intake", OutputDocTitle: "Customer Intake",
}},
}, {
Key: "biz_feasibility", Name: "Feasibility", Position: 1, Optional: true,
DocTasks: []workflowdef.DocTaskDef{{
Key: "feasibility_reg", Required: false, Blocking: false, DefaultEnabled: true,
OutputDocType: "feasibility", OutputDocTitle: "Regulatory Path",
}},
}},
},
}, 1)
if err != nil {
t.Fatalf("create workflow def: %v", err)
}
if _, err := wfSvc.PublishVersion(ctx, ver.WorkflowID, ver.Version, 1); err != nil {
t.Fatalf("publish workflow: %v", err)
}
creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
reviewerToken := loginTestUser(t, handler, "lizhuguan", "123456")
approverToken := loginTestUser(t, handler, "wangzong", "123456")
creatorID := fetchAuthUserID(t, handler, creatorToken)
reviewerID := fetchAuthUserID(t, handler, reviewerToken)
approverID := fetchAuthUserID(t, handler, approverToken)
// HTTP 创建中文名项目(验证 UTF-8 修复)
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{
Name: "PG PiAgent 治理链中文项目",
DeviceType: "Ultrasonic Device",
IntendedUse: "Tissue resection",
TargetMarket: "NMPA Class III",
})
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
withBearerAuth(reqCreate, creatorToken)
recCreate := httptest.NewRecorder()
handler.ServeHTTP(recCreate, reqCreate)
if recCreate.Code != http.StatusCreated {
t.Fatalf("create project: %d %s", recCreate.Code, recCreate.Body.String())
}
var proj projectworkflow.Project
if err := json.Unmarshal(recCreate.Body.Bytes(), &proj); err != nil {
t.Fatalf("decode project: %v", err)
}
t.Cleanup(func() { cleanupProject(t, pool, proj.ID) })
projectID := proj.ID
t.Logf("✓ [1. 项目创建] id=%d code=%s", projectID, proj.Code)
// 规划 + 激活
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=1", projectID, wfKey), nil)
withBearerAuth(reqDefault, creatorToken)
recDefault := httptest.NewRecorder()
handler.ServeHTTP(recDefault, reqDefault)
if recDefault.Code != http.StatusOK {
t.Fatalf("default plan: %d %s", recDefault.Code, recDefault.Body.String())
}
var plan projectinstance.WorkflowPlan
_ = json.Unmarshal(recDefault.Body.Bytes(), &plan)
for i := range plan.Stages {
for j := range plan.Stages[i].DocTasks {
plan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
}
}
}
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: plan, ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1})
reqSave := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/projects/%d/workflow-plan", projectID), bytes.NewReader(saveBody))
withBearerAuth(reqSave, creatorToken)
recSave := httptest.NewRecorder()
handler.ServeHTTP(recSave, reqSave)
if recSave.Code != http.StatusOK {
t.Fatalf("save plan: %d %s", recSave.Code, recSave.Body.String())
}
reqPreview := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/activation-preview", projectID), nil)
withBearerAuth(reqPreview, creatorToken)
recPreview := httptest.NewRecorder()
handler.ServeHTTP(recPreview, reqPreview)
var preview projectinstance.ActivationPreview
_ = json.Unmarshal(recPreview.Body.Bytes(), &preview)
activateBody, _ := json.Marshal(projectinstance.ActivateInput{
ConfirmationToken: preview.ConfirmationToken, ExpectedProjectVersion: preview.ExpectedProjectVersion,
ExpectedPlanRevision: preview.ExpectedPlanRevision,
})
reqActivate := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/projects/%d/workflow-instances/activate", projectID), bytes.NewReader(activateBody))
withBearerAuth(reqActivate, creatorToken)
recActivate := httptest.NewRecorder()
handler.ServeHTTP(recActivate, reqActivate)
if recActivate.Code != http.StatusOK {
t.Fatalf("activate: %d %s", recActivate.Code, recActivate.Body.String())
}
var activated projectinstance.ActivateResult
_ = json.Unmarshal(recActivate.Body.Bytes(), &activated)
instanceID := activated.Instance.ID
t.Logf("✓ [2. 工作流激活] instance_id=%d", instanceID)
// pi-agent 执行 intake(精简单步 WBS)
taskStart := time.Now()
taskBody, _ := json.Marshal(server.TaskRunRequestDTO{
TaskKey: "intake",
ProjectName: proj.Name,
DeviceType: proj.DeviceType,
IntendedUse: "Tissue resection",
TargetMarket: "NMPA Class III",
Backend: "pi-agent",
ProjectID: projectID,
AuthorID: creatorID,
ReviewerID: reviewerID,
ApproverID: approverID,
CustomSteps: []squad.StepSpec{{
ID: "step_01_intake", Role: "writer", Title: "Intake snapshot",
SubWorkspace: "tasks/01_intake", Mode: "execute",
Accept: []string{"Deliver output.md with project intake summary"},
}},
})
reqTask, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks/run", bytes.NewReader(taskBody))
withBearerAuth(reqTask, creatorToken)
respTask, err := client.Do(reqTask)
if err != nil {
t.Fatalf("pi-agent task run: %v", err)
}
defer respTask.Body.Close()
if respTask.StatusCode != http.StatusOK {
var buf bytes.Buffer
_, _ = buf.ReadFrom(respTask.Body)
t.Fatalf("pi-agent task HTTP %d: %s", respTask.StatusCode, buf.String())
}
var taskRes struct {
Success bool `json:"success"`
DocRecord *struct {
DocID string `json:"doc_id"`
} `json:"doc_record"`
ArtifactTitle string `json:"artifact_title"`
}
_ = json.NewDecoder(respTask.Body).Decode(&taskRes)
if !taskRes.Success {
t.Fatal("pi-agent intake task failed")
}
docID := ""
if taskRes.DocRecord != nil {
docID = taskRes.DocRecord.DocID
}
if docID == "" {
docID = fmt.Sprintf("doc_proj%d_intake", projectID)
}
t.Logf("✓ [3. pi-agent intake] doc=%s title=%s elapsed=%s", docID, taskRes.ArtifactTitle, time.Since(taskStart).Round(time.Second))
// 文档批准链
docEngine := srv.BizdevService().DocGov()
_ = docEngine.MarkDocumentContentUpdated(ctx, docID, "hash_pi_pg", false)
_, _ = docEngine.SubmitForReview(ctx, docID, creatorID, "author", "Author")
_, _ = docEngine.ReviewApprove(ctx, docID, reviewerID, "reviewer", "Reviewer", "ok")
_, err = docEngine.ApproverApprove(ctx, docID, approverID, "approver", "Approver", "ok")
if err != nil {
t.Fatalf("approve doc: %v", err)
}
t.Log("✓ [4. 文档批准] stage ready hook 已触发")
// 通知 + 阶段推进
reqNotify := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/notifications", projectID), nil)
withBearerAuth(reqNotify, creatorToken)
recNotify := httptest.NewRecorder()
handler.ServeHTTP(recNotify, reqNotify)
var notifyResp struct {
Items []map[string]any `json:"items"`
}
_ = json.Unmarshal(recNotify.Body.Bytes(), &notifyResp)
foundReady := false
for _, n := range notifyResp.Items {
if n["type"] == notification.TypeStageReadyToAdvance {
foundReady = true
break
}
}
if !foundReady {
t.Fatalf("expected stage_ready notification, got %+v", notifyResp.Items)
}
advanceBody, _ := json.Marshal(projectinstance.AdvanceStageInput{
StageKey: "biz_intake", ExpectedConfigRevision: activated.Instance.ConfigRevision,
})
reqAdvance := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/workflow-instances/%d/stages/biz_intake/advance", instanceID), bytes.NewReader(advanceBody))
withBearerAuth(reqAdvance, creatorToken)
recAdvance := httptest.NewRecorder()
handler.ServeHTTP(recAdvance, reqAdvance)
if recAdvance.Code != http.StatusOK {
t.Fatalf("advance: %d %s", recAdvance.Code, recAdvance.Body.String())
}
t.Log("✓ [5. 阶段推进] PG + pi-agent 治理全链路完成")
}