Persist validated_only shadow plans without executing, gate elevated tiers behind INTENT_PLAN_PROJECT_ALLOWLIST, and keep legacy chat routing for shadow.
133 lines
4.4 KiB
Go
133 lines
4.4 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/app/intentflow"
|
|
"chat-agent-system/internal/taskrun"
|
|
)
|
|
|
|
func TestTaskRunAdapterIdempotentExecute(t *testing.T) {
|
|
a := NewTaskRunAdapter()
|
|
exec := intentflow.StepExec{
|
|
ActionKey: "task.run", IdempotencyKey: "idem-1", ActionDigest: "d1",
|
|
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s", Environment: "dev"},
|
|
}
|
|
first, err := a.Execute(context.Background(), exec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := a.Execute(context.Background(), exec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.ResultRef != second.ResultRef {
|
|
t.Fatalf("replay ref %s vs %s", first.ResultRef, second.ResultRef)
|
|
}
|
|
if a.StartCount() != 1 {
|
|
t.Fatalf("starts = %d", a.StartCount())
|
|
}
|
|
}
|
|
|
|
func TestTaskRunAdapterRejectsQueryKey(t *testing.T) {
|
|
a := NewTaskRunAdapter()
|
|
_, err := a.Execute(context.Background(), intentflow.StepExec{ActionKey: "regulation.query", Principal: intentflow.Principal{UserID: 1}})
|
|
if err != intentflow.ErrPlanActionForbidden {
|
|
t.Fatalf("got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTaskRunAdapterDeniedAfterRevoke(t *testing.T) {
|
|
a := NewTaskRunAdapter()
|
|
a.Denied = true
|
|
_, err := a.Execute(context.Background(), intentflow.StepExec{ActionKey: "task.run", Principal: intentflow.Principal{UserID: 1}})
|
|
if err != intentflow.ErrPlanActionForbidden {
|
|
t.Fatalf("got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConfirmThenMuxExecutesGenerateOnce(t *testing.T) {
|
|
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
|
|
PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42},
|
|
})
|
|
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
|
query := intentflow.NewFakeActionOwner()
|
|
task := NewTaskRunAdapter()
|
|
t0 := time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)
|
|
p := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{
|
|
Owner: intentflow.OwnerMux{Query: intentflow.QueryGuard{Inner: query}, Task: task},
|
|
Now: func() time.Time { return t0 },
|
|
})
|
|
ctx := context.Background()
|
|
prin := intentflow.Principal{UserID: 7, Environment: "dev", ProjectID: 42, SessionID: "sess-u8"}
|
|
created, err := p.HandleTurn(ctx, intentflow.TurnInput{
|
|
Principal: prin, Text: "查一下相关法规,然后帮我生成报价单",
|
|
ClientTurnID: "turn-u8", RequestDigest: "d-u8",
|
|
})
|
|
if err != nil || created == nil || created.Plan == nil {
|
|
t.Fatalf("create: %+v %v", created, err)
|
|
}
|
|
if created.Plan.Status != intentflow.PlanAwaitingConfirmation {
|
|
t.Fatalf("status %s", created.Plan.Status)
|
|
}
|
|
if task.StartCount() != 0 {
|
|
t.Fatal("generate must not start before confirm")
|
|
}
|
|
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
|
|
Principal: prin, PlanID: created.Plan.PlanID, ExpectedRevision: created.Plan.Revision,
|
|
DecisionNonce: "u8-n", DecisionDigest: "u8-d", Kind: intentflow.DecisionConfirm,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
view, err := p.AdvanceEligible(ctx, "u8-worker", confirmed.PlanID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if view == nil {
|
|
t.Fatal("expected advanced plan")
|
|
}
|
|
if task.StartCount() != 1 {
|
|
t.Fatalf("generate starts = %d, plan=%+v", task.StartCount(), view)
|
|
}
|
|
_, err = p.AdvanceEligible(ctx, "u8-worker", confirmed.PlanID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if task.StartCount() != 1 {
|
|
t.Fatalf("replay must not restart generate, starts=%d", task.StartCount())
|
|
}
|
|
}
|
|
|
|
func TestLiveTaskStarterReplaysIdempotencyKey(t *testing.T) {
|
|
mgr := taskrun.NewManager(nil, nil)
|
|
start := LiveTaskStarter(mgr, false)
|
|
exec := intentflow.StepExec{
|
|
ActionKey: "task.run", IdempotencyKey: "plan-key-1", ActionDigest: "d",
|
|
Principal: intentflow.Principal{UserID: 3, ProjectID: 9, SessionID: "sess-live"},
|
|
}
|
|
a, err := start(context.Background(), exec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := start(context.Background(), exec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a == "" || a != b {
|
|
t.Fatalf("replay run id %q vs %q", a, b)
|
|
}
|
|
}
|
|
|
|
func TestTaskGenerateRealPIStopGate(t *testing.T) {
|
|
// Permanent redirect, not a TODO: internal/app has no PG pool / pi-agent runner
|
|
// harness, and internal/server already imports internal/app (server.go), so pulling
|
|
// internal/server's real-PI+PG fixtures in here would create an import cycle.
|
|
// Real PI + PG + revoke/restart evidence for this stop-gate lives in
|
|
// TestMultiIntentU8_RealPI_ConfirmGenerate_PG (internal/server), which exercises the
|
|
// same LiveTaskStarter path against a live pi-agent runner + isolated PG.
|
|
t.Skip("redirect: run TestMultiIntentU8_RealPI_ConfirmGenerate_PG with TEST_DATABASE_URL + runner")
|
|
}
|