Replace the U8 stop-gate with a PG+PI confirm/generate harness and add a mixed-duty governance long chain covering cross-user approve and none non-blocking advance. Co-authored-by: Cursor <cursoragent@cursor.com>
323 lines
12 KiB
Go
323 lines
12 KiB
Go
package server_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/app"
|
|
"chat-agent-system/internal/app/intentflow"
|
|
"chat-agent-system/internal/app/uow"
|
|
"chat-agent-system/internal/bizdev"
|
|
"chat-agent-system/internal/events"
|
|
"chat-agent-system/internal/projectinstance"
|
|
"chat-agent-system/internal/projectworkflow"
|
|
"chat-agent-system/internal/taskrun"
|
|
"chat-agent-system/internal/workflowdef"
|
|
)
|
|
|
|
// TestMultiIntentU8_RealPI_ConfirmGenerate_PG closes the U8 real-PI evidence gap:
|
|
// confirm-before-start, live pi-agent generate via TaskRunAdapter, idempotent replay,
|
|
// revoke, and PG plan recovery after a fresh Plans wiring (restart simulation).
|
|
func TestMultiIntentU8_RealPI_ConfirmGenerate_PG(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping slow U8 real-PI PG E2E in short mode")
|
|
}
|
|
if !piAgentRunnerHealthy() {
|
|
t.Skip("pi-agent runner not available at http://127.0.0.1:18865/health")
|
|
}
|
|
if os.Getenv("PI_AGENT_RUNNER_URL") == "" {
|
|
t.Setenv("PI_AGENT_RUNNER_URL", "http://127.0.0.1:18865")
|
|
}
|
|
|
|
pool := openPGPoolWithMigrations(t)
|
|
if pool == nil {
|
|
return
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
handler, srv := setupPGPiAgentServer(t, pool)
|
|
|
|
seedOfficialWorkflowManifest(t, pool, workflowdef.OfficialKeyMain)
|
|
|
|
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)
|
|
|
|
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{
|
|
Name: fmt.Sprintf("U8 Wave2 PI %d", time.Now().UnixNano()%1_000_000),
|
|
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
|
|
|
|
reqDefault := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/projects/%d/workflow-plan/default?workflow_key=%s&version=0", projectID, workflowdef.OfficialKeyMain), 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 wfPlan projectinstance.WorkflowPlan
|
|
_ = json.Unmarshal(recDefault.Body.Bytes(), &wfPlan)
|
|
ensureProjectMembers(t, pool, projectID, map[int64]string{
|
|
creatorID: "owner",
|
|
reviewerID: "member",
|
|
approverID: "member",
|
|
})
|
|
for i := range wfPlan.Stages {
|
|
for j := range wfPlan.Stages[i].DocTasks {
|
|
wfPlan.Stages[i].DocTasks[j].Duties = projectinstance.DocDuty{
|
|
AuthorUserID: creatorID, ReviewerUserID: reviewerID, ApproverUserID: approverID,
|
|
}
|
|
}
|
|
}
|
|
saveBody, _ := json.Marshal(projectinstance.SavePlanInput{Plan: wfPlan, 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())
|
|
}
|
|
|
|
sessionID := fmt.Sprintf("sess-u8-wave2-%d", projectID)
|
|
sessBody, _ := json.Marshal(map[string]any{
|
|
"session_id": sessionID,
|
|
"project_id": projectID,
|
|
"user_id": creatorID,
|
|
"title": "U8 Wave2 intent plan",
|
|
})
|
|
reqSess := httptest.NewRequest(http.MethodPost, "/api/v1/chat/sessions", bytes.NewReader(sessBody))
|
|
withBearerAuth(reqSess, creatorToken)
|
|
recSess := httptest.NewRecorder()
|
|
handler.ServeHTTP(recSess, reqSess)
|
|
if recSess.Code != http.StatusOK && recSess.Code != http.StatusCreated {
|
|
t.Fatalf("upsert chat session: %d %s", recSess.Code, recSess.Body.String())
|
|
}
|
|
|
|
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
|
|
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
|
|
|
taskOwner := app.NewTaskRunAdapter()
|
|
var runID string
|
|
taskReq := bizdev.TaskRunRequest{
|
|
TaskKey: "intake",
|
|
ProjectName: proj.Name,
|
|
DeviceType: proj.DeviceType,
|
|
IntendedUse: "Tissue resection",
|
|
TargetMarket: "NMPA Class III",
|
|
Backend: "pi-agent",
|
|
ProjectID: projectID,
|
|
UserID: creatorID,
|
|
AuthorID: creatorID,
|
|
ReviewerID: reviewerID,
|
|
ApproverID: approverID,
|
|
SessionID: sessionID,
|
|
Fields: map[string]string{
|
|
"customer_name": "U8 Wave2 真实 PI 客户",
|
|
"device_type": proj.DeviceType,
|
|
"intended_use": "Tissue resection",
|
|
"target_market": "NMPA Class III",
|
|
},
|
|
Materials: []bizdev.MaterialInput{{
|
|
Filename: "project_brief.md",
|
|
Content: []byte("# U8 Wave2\n\n- 产品:超声软组织手术系统\n- 目标:确认后真实 PI generate\n"),
|
|
}},
|
|
}
|
|
taskOwner.Starter = func(ctx context.Context, exec intentflow.StepExec) (string, error) {
|
|
if existing, err := srv.TaskRuns().FindByIdempotencyKey(ctx, exec.IdempotencyKey); err != nil {
|
|
return "", err
|
|
} else if existing != nil && existing.RunID != "" {
|
|
runID = existing.RunID
|
|
return existing.RunID, nil
|
|
}
|
|
rec := &taskrun.RunRecord{
|
|
SessionID: exec.Principal.SessionID,
|
|
ProjectID: exec.Principal.ProjectID,
|
|
UserID: exec.Principal.UserID,
|
|
IdempotencyKey: exec.IdempotencyKey,
|
|
TaskKey: "intake",
|
|
Status: "pending",
|
|
}
|
|
req := taskReq
|
|
req.UserID = exec.Principal.UserID
|
|
req.ProjectID = exec.Principal.ProjectID
|
|
req.SessionID = exec.Principal.SessionID
|
|
created, err := srv.TaskRuns().CreateAndStart(ctx, "", rec, req, bizdev.StageRunRequest{}, "")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
runID = created.RunID
|
|
return created.RunID, nil
|
|
}
|
|
|
|
plans := intentflow.NewPlans(uow.NewPG(pool), intentflow.NewRepository(), intentflow.Options{
|
|
Owner: intentflow.OwnerMux{
|
|
Query: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()},
|
|
Task: taskOwner,
|
|
},
|
|
Events: events.PGSink{},
|
|
})
|
|
prin := intentflow.Principal{
|
|
UserID: creatorID, Environment: "test", ProjectID: projectID, SessionID: sessionID,
|
|
}
|
|
created, err := plans.CreatePlan(ctx, intentflow.CreateCommand{
|
|
Principal: prin, ClientTurnID: fmt.Sprintf("u8-turn-%d", projectID), RequestDigest: "u8-wave2-d1",
|
|
WritePlan: true, ExecutionMode: intentflow.ModeLive,
|
|
Steps: []intentflow.StepDraft{
|
|
{ActionKey: "regulation.query", ActionDigest: "u8-q", ExecutionMode: intentflow.ModeLive},
|
|
{ActionKey: "task.run", ActionDigest: "u8-w", ExecutionMode: intentflow.ModeLive},
|
|
},
|
|
})
|
|
if err != nil || created == nil {
|
|
t.Fatalf("create plan: %+v %v", created, err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM intent_plan_decisions WHERE plan_id=$1`, created.PlanID)
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM intent_plan_steps WHERE plan_id=$1`, created.PlanID)
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM intent_plans WHERE plan_id=$1`, created.PlanID)
|
|
})
|
|
if created.Status != intentflow.PlanAwaitingConfirmation {
|
|
t.Fatalf("status before confirm = %s", created.Status)
|
|
}
|
|
if taskOwner.StartCount() != 0 {
|
|
t.Fatal("generate must not start before confirm")
|
|
}
|
|
t.Logf("✓ [1. awaiting] plan_id=%s revision=%d starts=0", created.PlanID, created.Revision)
|
|
|
|
confirmed, err := plans.DecidePlan(ctx, intentflow.DecisionCommand{
|
|
Principal: prin, PlanID: created.PlanID, ExpectedRevision: created.Revision,
|
|
DecisionNonce: fmt.Sprintf("u8-n-%d", projectID), DecisionDigest: "u8-d",
|
|
Kind: intentflow.DecisionConfirm,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if taskOwner.StartCount() != 0 {
|
|
t.Fatal("confirm alone must not start generate")
|
|
}
|
|
|
|
piStart := time.Now()
|
|
view, err := plans.AdvanceEligible(ctx, "u8-wave2-worker", confirmed.PlanID)
|
|
if err != nil {
|
|
t.Fatalf("advance: %v", err)
|
|
}
|
|
if view == nil {
|
|
t.Fatal("expected advanced plan view")
|
|
}
|
|
if taskOwner.StartCount() != 1 {
|
|
t.Fatalf("generate starts = %d, plan=%+v", taskOwner.StartCount(), view)
|
|
}
|
|
if runID == "" {
|
|
t.Fatal("starter did not capture run id")
|
|
}
|
|
waitCtx, cancel := context.WithTimeout(ctx, 45*time.Minute)
|
|
defer cancel()
|
|
if err := srv.TaskRuns().WaitRunDone(waitCtx, runID); err != nil {
|
|
t.Fatalf("wait PI run %s: %v", runID, err)
|
|
}
|
|
doneRec, err := srv.TaskRuns().GetRun(runID)
|
|
if err != nil || doneRec == nil {
|
|
t.Fatalf("get run: %v", err)
|
|
}
|
|
if doneRec.Status != taskrun.StatusCompleted {
|
|
t.Fatalf("PI run status=%s want %s", doneRec.Status, taskrun.StatusCompleted)
|
|
}
|
|
t.Logf("✓ [2. real PI generate] run_id=%s status=%s elapsed=%s", runID, doneRec.Status, time.Since(piStart).Round(time.Second))
|
|
|
|
_, err = plans.AdvanceEligible(ctx, "u8-wave2-worker", confirmed.PlanID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if taskOwner.StartCount() != 1 {
|
|
t.Fatalf("replay must not restart generate, starts=%d", taskOwner.StartCount())
|
|
}
|
|
t.Log("✓ [3. idempotent replay] starts still 1")
|
|
|
|
taskOwner.Denied = true
|
|
_, err = taskOwner.Execute(ctx, intentflow.StepExec{
|
|
ActionKey: "task.run", IdempotencyKey: "post-revoke", ActionDigest: "x",
|
|
Principal: prin,
|
|
})
|
|
if err != intentflow.ErrPlanActionForbidden {
|
|
t.Fatalf("revoke want forbidden, got %v", err)
|
|
}
|
|
t.Log("✓ [4. revoke] TaskRunAdapter denied")
|
|
|
|
restarted := intentflow.NewPlans(uow.NewPG(pool), intentflow.NewRepository(), intentflow.Options{
|
|
Owner: intentflow.OwnerMux{
|
|
Query: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()},
|
|
Task: app.NewTaskRunAdapter(),
|
|
},
|
|
Events: events.PGSink{},
|
|
})
|
|
reloaded, err := restarted.GetPlan(ctx, prin, created.PlanID)
|
|
if err != nil || reloaded == nil {
|
|
t.Fatalf("reload plan after restart wiring: %v", err)
|
|
}
|
|
var taskOK bool
|
|
for _, s := range reloaded.Steps {
|
|
if s.ActionKey == "task.run" && s.Status == intentflow.StepSucceeded {
|
|
taskOK = true
|
|
break
|
|
}
|
|
}
|
|
if !taskOK {
|
|
t.Fatalf("restart recovery missing succeeded task.run: %+v", reloaded.Steps)
|
|
}
|
|
t.Logf("✓ [5. PG restart recovery] plan_id=%s status=%s", reloaded.PlanID, reloaded.Status)
|
|
|
|
srv.SetIntentPlansForTest(restarted)
|
|
reqGet := httptest.NewRequest(http.MethodGet, fmt.Sprintf("%s?project_id=%d&session_id=%s",
|
|
fmt.Sprintf("/api/v1/intent-plans/%s", created.PlanID), projectID, sessionID), nil)
|
|
withBearerAuth(reqGet, creatorToken)
|
|
recGet := httptest.NewRecorder()
|
|
handler.ServeHTTP(recGet, reqGet)
|
|
if recGet.Code != http.StatusOK {
|
|
t.Fatalf("GET plan after re-login: %d %s", recGet.Code, recGet.Body.String())
|
|
}
|
|
t.Logf("✓ [6. HTTP refresh] GET plan %d bytes", recGet.Body.Len())
|
|
}
|