Files
weichen c07d4020d0 feat(intentflow): I2 shadow tier, project allowlist, and rollout metrics
Persist validated_only shadow plans without executing, gate elevated tiers
behind INTENT_PLAN_PROJECT_ALLOWLIST, and keep legacy chat routing for shadow.
2026-09-23 15:10:26 +00:00

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