package services import ( "context" "errors" "strings" "sync/atomic" "testing" "code.tczkiot.com/wlw/ai-agent/contract" "code.tczkiot.com/wlw/ai-agent/internal/models" "code.tczkiot.com/wlw/ai-agent/internal/repositories" "github.com/glebarez/sqlite" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "gorm.io/gorm/schema" ) func TestBusinessActionToolRequiresMatchingCustomerAndReusesConfirmedExecution(t *testing.T) { t.Cleanup(func() { _ = SetBusinessActionTools(nil) }) database, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, }) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil { t.Fatalf("migrate invocation: %v", err) } sqls.SetDB(database) executions := 0 if err := SetBusinessActionTools([]contract.BusinessActionTool{{ Code: "business/card_resume", Description: "resume card", CustomerTypes: []string{"card"}, Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) { return "confirm resume", nil }, Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) { executions++ return &contract.BusinessActionResult{Message: "resumed"}, nil }, }}); err != nil { t.Fatalf("register action: %v", err) } tool, ok := BusinessActionToolService.ResolveForCustomerType("business/card_resume", "card") if !ok { t.Fatal("card action was not resolved") } if _, ok := BusinessActionToolService.ResolveForCustomerType("business/card_resume", "mall_user"); ok { t.Fatal("card action leaked to mall user") } ctx := contract.BusinessReadContext{ConversationID: 10, CustomerType: "card", CustomerID: 20} first, reused, err := BusinessActionToolService.Execute(context.Background(), 10, 30, "confirm-1", tool, ctx, nil) if err != nil || reused || first == nil || first.Message != "resumed" { t.Fatalf("first execution = %#v, reused=%t, err=%v", first, reused, err) } second, reused, err := BusinessActionToolService.Execute(context.Background(), 10, 30, "confirm-1", tool, ctx, nil) if err != nil || !reused || second == nil || second.Message != "resumed" || executions != 1 { t.Fatalf("reused execution = %#v, reused=%t, executions=%d, err=%v", second, reused, executions, err) } } func TestBusinessActionToolReauthorizesCurrentConfirmationBeforeIdempotencyClaim(t *testing.T) { authorized := 0 executed := 0 tool := contract.BusinessActionTool{ Code: "business/device_network_switch", Description: "switch network", CustomerTypes: []string{"device"}, Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) { return "confirm", nil }, AuthorizeConfirmation: func(_ context.Context, businessContext contract.BusinessReadContext, arguments map[string]any, checkPointID string) error { authorized++ if businessContext.RequestMessageID != 202 || businessContext.RequestID != "request-303" || checkPointID != "checkpoint-404" || arguments["slot"] != "backup" { return errors.New("current confirmation proof does not match") } return errors.New("current confirmation request is not authorized") }, Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) { executed++ return &contract.BusinessActionResult{Message: "switched"}, nil }, } _, reused, err := BusinessActionToolService.Execute( context.Background(), 101, 1, "checkpoint-404", tool, contract.BusinessReadContext{ConversationID: 101, RequestMessageID: 202, RequestID: "request-303"}, map[string]any{"slot": "backup"}, ) if err == nil || reused { t.Fatalf("unauthorized confirmation should fail before claiming: reused=%v err=%v", reused, err) } if authorized != 1 || executed != 0 { t.Fatalf("authorize=%d execute=%d; action must not execute without current confirmation proof", authorized, executed) } } func TestBusinessActionToolPersistsUnclassifiedFailureAsUnknownOutcome(t *testing.T) { database, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, }) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil { t.Fatalf("migrate invocation: %v", err) } sqls.SetDB(database) internalErr := errors.New("upstream rejected package order") tool := contract.BusinessActionTool{ Code: "business/card_package_order", Description: "order package", CustomerTypes: []string{"card"}, Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) { return "confirm", nil }, Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) { return nil, contract.NewBusinessActionError("套餐已达到购买次数限制", internalErr) }, } _, _, err = BusinessActionToolService.Execute(context.Background(), 12, 32, "confirm-failed", tool, contract.BusinessReadContext{}, nil) if err == nil || err.Error() != "套餐已达到购买次数限制" { t.Fatalf("execute err = %v", err) } item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(database, 12, tool.Code, "confirm-failed") if item == nil || item.Status != agentToolInvocationStatusUnknownOutcome || item.ErrorMessage != internalErr.Error() { t.Fatalf("stored invocation = %#v", item) } } func TestBusinessActionToolDoesNotReplayAfterResponseTimeout(t *testing.T) { database, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, }) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil { t.Fatalf("migrate invocation: %v", err) } sqls.SetDB(database) executions := 0 tool := contract.BusinessActionTool{ Code: "business/order", Description: "create order", Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) { return "confirm", nil }, Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) { executions++ return nil, context.DeadlineExceeded }, } if _, _, err := BusinessActionToolService.Execute(context.Background(), 40, 50, "confirm-timeout", tool, contract.BusinessReadContext{}, nil); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("first execute err = %v", err) } if _, reused, err := BusinessActionToolService.Execute(context.Background(), 40, 50, "confirm-timeout", tool, contract.BusinessReadContext{}, nil); err == nil || !reused { t.Fatalf("second execute reused=%t err=%v", reused, err) } if executions != 1 { t.Fatalf("host operation executed %d times", executions) } item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(database, 40, tool.Code, "confirm-timeout") if item == nil || item.Status != agentToolInvocationStatusUnknownOutcome { t.Fatalf("stored invocation = %#v", item) } } func TestBusinessActionToolMarksUnknownWhenCompletionPersistenceFails(t *testing.T) { database, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, }) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil { t.Fatalf("migrate invocation: %v", err) } sqls.SetDB(database) var failFirstUpdate atomic.Bool if err := database.Callback().Update().Before("gorm:update").Register("test:fail_completed_persistence", func(tx *gorm.DB) { if !failFirstUpdate.Swap(true) { tx.AddError(errors.New("completion persistence unavailable")) } }); err != nil { t.Fatalf("register update callback: %v", err) } executions := 0 tool := contract.BusinessActionTool{ Code: "business/provision", Description: "provision service", Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) { return "confirm", nil }, Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) { executions++ return &contract.BusinessActionResult{Message: "provisioned"}, nil }, } if _, _, err := BusinessActionToolService.Execute(context.Background(), 42, 52, "confirm-persist-failed", tool, contract.BusinessReadContext{}, nil); err == nil { t.Fatal("expected completion persistence failure") } item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(database, 42, tool.Code, "confirm-persist-failed") if item == nil || item.Status != agentToolInvocationStatusUnknownOutcome { t.Fatalf("stored invocation = %#v", item) } if _, reused, err := BusinessActionToolService.Execute(context.Background(), 42, 52, "confirm-persist-failed", tool, contract.BusinessReadContext{}, nil); err == nil || !reused { t.Fatalf("second execute reused=%t err=%v", reused, err) } if executions != 1 { t.Fatalf("external action replayed %d times", executions) } } func TestBusinessActionToolRetriesExplicitPreSideEffectFailure(t *testing.T) { database, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, }) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := database.AutoMigrate(&models.AgentToolInvocation{}); err != nil { t.Fatalf("migrate invocation: %v", err) } sqls.SetDB(database) executions := 0 tool := contract.BusinessActionTool{ Code: "business/cancel_order", Description: "cancel order", Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) { return "confirm", nil }, Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) { executions++ if executions == 1 { return nil, contract.NewRetryableBusinessActionError("订单状态暂不可办理", errors.New("precondition changed")) } return &contract.BusinessActionResult{Message: "cancelled"}, nil }, } if _, _, err := BusinessActionToolService.Execute(context.Background(), 41, 51, "confirm-retry", tool, contract.BusinessReadContext{}, nil); err == nil { t.Fatal("expected first precondition failure") } result, reused, err := BusinessActionToolService.Execute(context.Background(), 41, 51, "confirm-retry", tool, contract.BusinessReadContext{}, nil) if err != nil || reused || result == nil || result.Message != "cancelled" || executions != 2 { t.Fatalf("retry result=%#v reused=%t executions=%d err=%v", result, reused, executions, err) } }