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

252 lines
7.9 KiB
Go

package intentflow_test
import (
"context"
"testing"
"chat-agent-system/internal/app/intentflow"
)
func TestHandleTurnAutoReadPersistsAndExecutesQueries(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierAutoRead, ProjectAllowlist: []int64{42},
})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
intentflow.ResetMetricsForTest()
p, _, owner := testPlans()
ctx := context.Background()
res, err := p.HandleTurn(ctx, intentflow.TurnInput{
Principal: principal(), Text: "查一下项目风险,然后查相关法规",
ClientTurnID: "turn-ar-1", RequestDigest: "d-ar-1",
})
if err != nil {
t.Fatal(err)
}
if res == nil || res.Plan == nil {
t.Fatal("auto_read must persist a plan")
}
if res.Plan.Status != intentflow.PlanExecuting && res.Plan.Status != intentflow.PlanCompleted {
t.Fatalf("status %s", res.Plan.Status)
}
again, err := p.HandleTurn(ctx, intentflow.TurnInput{
Principal: principal(), Text: "查一下项目风险,然后查相关法规",
ClientTurnID: "turn-ar-1", RequestDigest: "d-ar-1",
})
if err != nil {
t.Fatal(err)
}
if again.Plan.PlanID != res.Plan.PlanID {
t.Fatal("replay must reuse plan")
}
total := 0
for _, s := range res.Plan.Steps {
total += owner.ExecCount(s.StepID)
}
_ = total
if owner.ExecCount("") < 0 {
t.Fatal("exec map")
}
n := 0
for _, s := range again.Plan.Steps {
// idempotency keys are not step IDs; count via succeeded status
if s.Status == intentflow.StepSucceeded {
n++
}
}
if n == 0 {
t.Fatalf("expected executed steps, plan=%+v", again.Plan)
}
}
func TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierAutoRead, ProjectAllowlist: []int64{42},
})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
p, _, _ := testPlans()
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
Principal: principal(), Text: "查法规并生成报价单",
ClientTurnID: "turn-w", RequestDigest: "d-w",
})
if err != nil {
t.Fatal(err)
}
if res.Plan != nil {
t.Fatalf("writes must not persist at auto_read: %+v", res.Plan)
}
}
func TestQueryGuardRejectsGenerate(t *testing.T) {
g := intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()}
_, err := g.Execute(context.Background(), intentflow.StepExec{ActionKey: "task.run", Principal: principal()})
if err == nil {
t.Fatal("task.run must be forbidden by QueryGuard")
}
}
func TestHandleTurnConfirmPersistsAwaitingWithoutExecute(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42},
})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
p, _, owner := testPlans()
ctx := context.Background()
res, err := p.HandleTurn(ctx, intentflow.TurnInput{
Principal: principal(),
Text: "查一下相关法规,然后帮我生成报价单",
ClientTurnID: "turn-cf-1",
RequestDigest: "d-cf-1",
})
if err != nil {
t.Fatal(err)
}
if res == nil || res.Plan == nil {
t.Fatal("confirm tier must persist a write plan")
}
if res.Plan.Status != intentflow.PlanAwaitingConfirmation {
t.Fatalf("status %s", res.Plan.Status)
}
if len(res.Plan.Steps) < 2 {
t.Fatalf("expected query+generate steps, got %+v", res.Plan.Steps)
}
hasGenerate := false
for _, s := range res.Plan.Steps {
if s.ActionKey == "task.run" || s.ActionKey == "task.run.adhoc" {
hasGenerate = true
}
if s.Status != intentflow.StepPending {
t.Fatalf("confirm must not execute before decision, step=%+v", s)
}
}
if !hasGenerate {
t.Fatalf("expected a generate step, plan=%+v", res.Plan)
}
if owner.ExecCount("") != 0 {
t.Fatal("no owner exec expected before confirm")
}
again, err := p.HandleTurn(ctx, intentflow.TurnInput{
Principal: principal(),
Text: "查一下相关法规,然后帮我生成报价单",
ClientTurnID: "turn-cf-1",
RequestDigest: "d-cf-1",
})
if err != nil {
t.Fatal(err)
}
if again.Plan.PlanID != res.Plan.PlanID {
t.Fatal("same turn must reuse the awaiting plan")
}
}
func TestHandleTurnConfirmRejectsSecondActiveWritePlan(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42},
})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
p, _, _ := testPlans()
ctx := context.Background()
first, err := p.HandleTurn(ctx, intentflow.TurnInput{
Principal: principal(),
Text: "查一下相关法规,然后帮我生成报价单",
ClientTurnID: "turn-cf-a",
RequestDigest: "d-cf-a",
})
if err != nil || first == nil || first.Plan == nil {
t.Fatalf("first plan: %+v %v", first, err)
}
_, err = p.HandleTurn(ctx, intentflow.TurnInput{
Principal: principal(),
Text: "查一下相关法规,然后帮我生成报价单",
ClientTurnID: "turn-cf-b",
RequestDigest: "d-cf-b",
})
if err != intentflow.ErrPlanDecisionConflict {
t.Fatalf("second write plan must conflict, got %v", err)
}
}
func TestHandleTurnShadowPersistsValidatedOnlyWithoutExecute(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierShadow, ProjectAllowlist: []int64{42},
})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
intentflow.ResetMetricsForTest()
p, _, owner := testPlans()
ctx := context.Background()
res, err := p.HandleTurn(ctx, intentflow.TurnInput{
Principal: principal(), Text: "查一下项目风险,然后查相关法规",
ClientTurnID: "turn-sh-1", RequestDigest: "d-sh-1",
})
if err != nil {
t.Fatal(err)
}
if res == nil || res.Plan == nil {
t.Fatal("shadow must persist a plan")
}
if res.Plan.Status != intentflow.PlanProposed {
t.Fatalf("shadow status = %s, want proposed", res.Plan.Status)
}
if res.Plan.ExecutionMode != intentflow.ModeValidatedOnly {
t.Fatalf("shadow mode = %s", res.Plan.ExecutionMode)
}
for _, s := range res.Plan.Steps {
if s.Status != intentflow.StepPending {
t.Fatalf("shadow must not execute, step=%+v", s)
}
if s.ExecutionMode != intentflow.ModeValidatedOnly {
t.Fatalf("shadow step mode = %s", s.ExecutionMode)
}
}
if owner.ExecCount("") != 0 {
t.Fatal("shadow must not call action owner")
}
snap := intentflow.Metrics().Snapshot()
if snap.ShadowPlans < 1 || snap.CounterfactualWouldExecute < 1 {
t.Fatalf("metrics = %+v", snap)
}
}
func TestHandleTurnElevatedDeniedWithoutAllowlist(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
intentflow.ResetMetricsForTest()
p, _, _ := testPlans()
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
Principal: principal(), Text: "查一下项目风险,然后查相关法规",
ClientTurnID: "turn-deny", RequestDigest: "d-deny",
})
if err != nil {
t.Fatal(err)
}
if res.Plan != nil {
t.Fatalf("empty allowlist must deny elevated tier: %+v", res.Plan)
}
if intentflow.Metrics().Snapshot().AllowlistDenied < 1 {
t.Fatal("expected allowlist_denied metric")
}
}
func TestHandleTurnShadowObservesConfirmClassWithoutExecuting(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
PlanTier: intentflow.TierShadow, ProjectAllowlist: []int64{intentflow.AllowAllProjects},
})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
p, _, owner := testPlans()
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
Principal: principal(), Text: "查一下相关法规,然后帮我生成报价单",
ClientTurnID: "turn-sh-cf", RequestDigest: "d-sh-cf",
})
if err != nil {
t.Fatal(err)
}
if res == nil || res.Plan == nil {
t.Fatal("shadow must observe confirm-class proposal")
}
if res.Plan.ExecutionMode != intentflow.ModeValidatedOnly || res.Plan.Status != intentflow.PlanProposed {
t.Fatalf("plan=%+v", res.Plan)
}
if owner.ExecCount("") != 0 {
t.Fatal("shadow confirm observe must not execute")
}
}