package growthreport import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" ) func TestAgentRouterClientExecutesToolsThenReturnsStructuredCopy(t *testing.T) { turn := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ if r.URL.Path != "/v1/chat/completions" { t.Fatalf("path = %q", r.URL.Path) } if r.Header.Get("Authorization") != "Bearer key" || r.Header.Get("User-Agent") != agentRouterUserAgent { t.Fatalf("provider headers are missing") } var request map[string]any if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } if request["reasoning_effort"] != "medium" || request["parallel_tool_calls"] != false { t.Fatalf("request controls = %#v", request) } w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"finish_reason":"tool_calls","message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_event_evidence","arguments":"{\"evidenceId\":\"event_teacher\"}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":1}}`)) return } messages := request["messages"].([]any) toolMessage := messages[len(messages)-1].(map[string]any) if toolMessage["role"] != "tool" || toolMessage["tool_call_id"] != "call_1" { t.Fatalf("tool result message = %#v", toolMessage) } _, _ = w.Write([]byte(`{"id":"chat-2","choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"{\"eventRecommendations\":[{\"evidenceId\":\"event_teacher\",\"priorityPercent\":40,\"stance\":\"PROMOTE\",\"relevanceReason\":\"این مناسبت با الگوی فروش کسب‌وکار هم‌خوان است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_yalda\",\"priorityPercent\":35,\"stance\":\"PREPARE\",\"relevanceReason\":\"این رویداد با کانال فروش شما مرتبط است.\",\"insightBasis\":\"BUSINESS_PROFILE\"},{\"evidenceId\":\"event_nowruz\",\"priorityPercent\":25,\"stance\":\"CAUTION\",\"relevanceReason\":\"این بازه برای برنامه‌ریزی عرضه مناسب است.\",\"insightBasis\":\"BUSINESS_PROFILE\"}]}"}}],"usage":{"prompt_tokens":3,"completion_tokens":4}}`)) })) defer server.Close() executor := &testTools{} client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second, EnableTools: true}, server.Client()) var generator CopyGenerator = client copy, usage, err := generator.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), executor) if err != nil { t.Fatal(err) } if len(copy.EventRecommendations) != 3 || executor.calls != 1 || executor.evidenceID != "event_teacher" || turn != 2 || usage.InputTokens != 5 || usage.OutputTokens != 5 || usage.ProviderRequests != 2 { t.Fatalf("copy=%+v calls=%d evidenceID=%q turns=%d usage=%+v", copy, executor.calls, executor.evidenceID, turn, usage) } } func TestAgentRouterClientRetriesRejectedSemanticOutput(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests++ copy := validRankedCopy() if requests == 1 { copy.EventRecommendations[0].RelevanceReason = "رشد ۲۰ درصدی پیش‌بینی می‌شود." } content, err := json.Marshal(copy) if err != nil { t.Fatal(err) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "chat", "choices": []any{map[string]any{ "finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": string(content)}, }}, "usage": map[string]any{"prompt_tokens": 2, "completion_tokens": 1}, }) })) defer server.Close() client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second}, server.Client()) _, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), nil) if err != nil { t.Fatal(err) } if requests != 2 || usage.ProviderRequests != 2 { t.Fatalf("requests=%d usage=%+v", requests, usage) } } func TestAgentRouterClientPreservesSemanticErrorWhenRetryFails(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests++ if requests == 1 { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"invalid","choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"{\"eventRecommendations\":[]}"}}]}`)) return } http.Error(w, "provider unavailable", http.StatusInternalServerError) })) defer server.Close() client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second}, server.Client()) _, _, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), nil) if err == nil || !strings.Contains(err.Error(), "invalid event recommendation count") || !strings.Contains(err.Error(), "http 500") { t.Fatalf("error=%v", err) } } func TestAgentRouterClientForcesFinalResponseAtToolLimit(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ var request map[string]any if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } w.Header().Set("Content-Type", "application/json") if requests <= maxAvalAIToolCalls { _ = json.NewEncoder(w).Encode(map[string]any{ "id": "chat", "choices": []any{map[string]any{ "finish_reason": "tool_calls", "message": map[string]any{ "role": "assistant", "tool_calls": []any{map[string]any{ "id": "call", "type": "function", "function": map[string]any{"name": "get_growth_context", "arguments": "{}"}, }}, }, }}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1}, }) return } if _, hasTools := request["tools"]; hasTools { t.Error("tools remain available on the forced final turn") } content, err := json.Marshal(validRankedCopy()) if err != nil { t.Fatal(err) } _ = json.NewEncoder(w).Encode(map[string]any{ "id": "final", "choices": []any{map[string]any{ "finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": string(content)}, }}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1}, }) })) defer server.Close() executor := &testTools{} client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second, EnableTools: true}, server.Client()) _, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), executor) if err != nil { t.Fatal(err) } if requests != maxAvalAIProviderTurns || executor.calls != maxAvalAIToolCalls || usage.ProviderRequests != int64(maxAvalAIProviderTurns) { t.Fatalf("requests=%d calls=%d usage=%+v", requests, executor.calls, usage) } } func TestAgentRouterClientDefaultsToDirectStructuredGeneration(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var request map[string]any if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatal(err) } if _, hasTools := request["tools"]; hasTools { t.Error("default AgentRouter request unexpectedly advertises tools") } content, err := json.Marshal(validRankedCopy()) if err != nil { t.Fatal(err) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "direct", "choices": []any{map[string]any{ "finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": string(content)}, }}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1}, }) })) defer server.Close() client := NewAgentRouterClient(AgentRouterConfig{BaseURL: server.URL, APIKey: "key", Model: "gpt-test", Timeout: time.Second}, server.Client()) _, usage, err := client.GenerateWithTools(context.Background(), json.RawMessage(`{"events":[]}`), profileKnownEvidence(), &testTools{}) if err != nil { t.Fatal(err) } if usage.ProviderRequests != 1 { t.Fatalf("usage=%+v", usage) } } func TestParseAgentRouterCopyAdaptsBareRecommendationArray(t *testing.T) { known := KnownEvidence{ "event:education-enrollment-autumn-1405": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event:cyber-monday-2026": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event:black-friday-2026": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, } message := agentRouterChatMessage{Content: `[ {"eventId":"education-enrollment-autumn-1405","priorityShare":50,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"هم‌خوانی مستقیم با آموزش آنلاین؛ مرجع شواهد: event:education-enrollment-autumn-1405"}, {"eventId":"cyber-monday-2026","priorityShare":30,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"تناسب موضوعی با خدمات شبکه و اینترنت؛ مرجع شواهد: event:cyber-monday-2026"}, {"eventId":"black-friday-2026","priorityShare":20,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"فرصتی تجاری برای تقویت فروش آنلاین؛ مرجع شواهد: event:black-friday-2026"} ]`} copy, err := parseAgentRouterCopy(message, known) if err != nil { t.Fatal(err) } if len(copy.EventRecommendations) != 3 || copy.EventRecommendations[0].EvidenceID != "event:education-enrollment-autumn-1405" || copy.EventRecommendations[0].PriorityPercent != 50 { t.Fatalf("copy=%+v", copy) } if copy.EventRecommendations[0].RelevanceReason != "هم‌خوانی مستقیم با آموزش آنلاین" { t.Fatalf("reason=%q", copy.EventRecommendations[0].RelevanceReason) } } func TestParseAgentRouterCopyEnforcesDeterministicStancePolicy(t *testing.T) { known := KnownEvidence{ "event:decline": {InsightBasis: InsightBasisBusinessProfile, MaterialDecline: true, Promotable: true}, "event:prepare": {InsightBasis: InsightBasisBusinessProfile, Promotable: false}, "event:promote": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, } message := agentRouterChatMessage{Content: `[ {"eventId":"decline","priorityShare":50,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"افت معنادار پیشین نیازمند رویکرد محتاطانه است"}, {"eventId":"prepare","priorityShare":30,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"این فرصت برای آماده سازی عملیات کسب و کار مناسب است"}, {"eventId":"promote","priorityShare":20,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","reason":"این فرصت با فروش آنلاین کسب و کار هم خوانی دارد"} ]`} copy, err := parseAgentRouterCopy(message, known) if err != nil { t.Fatal(err) } if copy.EventRecommendations[0].Stance != StanceCaution { t.Fatalf("decline stance=%q", copy.EventRecommendations[0].Stance) } if copy.EventRecommendations[1].Stance != StancePrepare { t.Fatalf("non-promotable stance=%q", copy.EventRecommendations[1].Stance) } if copy.EventRecommendations[2].Stance != StancePromote { t.Fatalf("promotable stance=%q", copy.EventRecommendations[2].Stance) } } func TestParseAgentRouterCopyRemovesRedundantEvidenceSuffixFromObject(t *testing.T) { known := KnownEvidence{ "event:education-enrollment-autumn-1405": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event:cyber-monday-2026": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event:black-friday-2026": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, } message := agentRouterChatMessage{Content: `{"eventRecommendations":[ {"evidenceId":"event:education-enrollment-autumn-1405","priorityPercent":45,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با آموزش آنلاین هم خوانی مستقیم دارد؛ شواهد event:education-enrollment-autumn-1405 ارتباط مستقیم را نشان می دهد."}, {"evidenceId":"event:cyber-monday-2026","priorityPercent":35,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با خدمات شبکه و اینترنت تناسب دارد؛ مستند به event:cyber-monday-2026."}, {"evidenceId":"event:black-friday-2026","priorityPercent":20,"stance":"CAUTION","insightBasis":"BUSINESS_PROFILE","relevanceReason":"برای فروش آنلاین قابل استفاده است؛ مستند به event:black-friday-2026."} ]}`} copy, err := parseAgentRouterCopy(message, known) if err != nil { t.Fatal(err) } if copy.EventRecommendations[0].RelevanceReason != "با آموزش آنلاین هم خوانی مستقیم دارد" { t.Fatalf("reason=%q", copy.EventRecommendations[0].RelevanceReason) } } func TestParseAgentRouterCopySelectsTopThreeFromOverCompleteObject(t *testing.T) { known := KnownEvidence{ "event:education": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event:black": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event:cyber": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event:yalda": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, "event:nowruz": {InsightBasis: InsightBasisBusinessProfile, Promotable: true}, } message := agentRouterChatMessage{Content: `{"eventRecommendations":[ {"evidenceId":"event:yalda","priorityPercent":15,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با خرید مناسبتی و فروش حضوری هم خوانی دارد."}, {"evidenceId":"event:black","priorityPercent":25,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"برای تقویت فروش آنلاین و تبلیغات مناسب است."}, {"evidenceId":"event:education","priorityPercent":30,"stance":"PROMOTE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با کانال های فعال کسب و کار هم راستا است."}, {"evidenceId":"event:nowruz","priorityPercent":10,"stance":"PREPARE","insightBasis":"BUSINESS_PROFILE","relevanceReason":"برای آماده سازی پیشنهادهای فروش مناسب است."}, {"evidenceId":"event:cyber","priorityPercent":20,"stance":"CAUTION","insightBasis":"BUSINESS_PROFILE","relevanceReason":"با فروش از مسیر وب سایت تناسب دارد."} ]}`} copy, err := parseAgentRouterCopy(message, known) if err != nil { t.Fatal(err) } if len(copy.EventRecommendations) != 3 { t.Fatalf("recommendations=%d", len(copy.EventRecommendations)) } wantIDs := []string{"event:education", "event:black", "event:cyber"} total := 0 for index, recommendation := range copy.EventRecommendations { if recommendation.EvidenceID != wantIDs[index] { t.Fatalf("recommendation[%d].evidenceId=%q", index, recommendation.EvidenceID) } if index > 0 && recommendation.PriorityPercent > copy.EventRecommendations[index-1].PriorityPercent { t.Fatalf("priorities are not descending: %+v", copy.EventRecommendations) } total += recommendation.PriorityPercent } if total != 100 { t.Fatalf("priority total=%d", total) } }