package dialogue import ( "context" "sync" "testing" "time" "ai-operator/internal/ai" "ai-operator/internal/call" "ai-operator/internal/dialogue/state" "ai-operator/internal/tools" ) func TestOrchestratorToolFlow(t *testing.T) { o := NewMemoryOrchestrator(nil) _, err := o.StartCall(context.Background(), call.CallSession{CallID: "c1", AsteriskChannelID: "c1", CallerNumber: "+77771234567", Route: "test", StartedAt: time.Now()}) if err != nil { t.Fatal(err) } s, ok := o.GetSession("c1") if !ok || s.State != state.StateReadyToHelp { t.Fatalf("state=%s ok=%t", s.State, ok) } denied := o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t1", Name: tools.SearchKnowledgeBase}) if denied.Error != "knowledge_base_unavailable" { t.Fatalf("expected knowledge_base_unavailable, got %+v", denied) } res := o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t2", Name: tools.SetLanguage, Arguments: map[string]any{"language": "ru"}}) if res.Error != "" { t.Fatalf("set language failed: %+v", res) } s, _ = o.GetSession("c1") if s.State != state.StateReadyToHelp { t.Fatalf("state=%s", s.State) } denied = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t3", Name: tools.SearchKnowledgeBase}) if denied.Error != "knowledge_base_unavailable" { t.Fatalf("expected knowledge_base_unavailable, got %+v", denied) } res = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t4", Name: tools.SetRegion, Arguments: map[string]any{"region_code": "almaty_city"}}) if res.Error != "" { t.Fatalf("set region failed: %+v", res) } s, _ = o.GetSession("c1") if s.State != state.StateReadyToHelp { t.Fatalf("state=%s", s.State) } res = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t5", Name: tools.SearchKnowledgeBase}) if res.Error != "knowledge_base_unavailable" { t.Fatalf("expected knowledge_base_unavailable, got %+v", res) } res = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t6", Name: tools.RequestHumanHandoff}) if res.Error != "" { t.Fatalf("handoff failed: %+v", res) } s, _ = o.GetSession("c1") if s.State != state.StateHandoff { t.Fatalf("state=%s", s.State) } res = o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "t7", Name: tools.EndCall}) if res.Error != "" { t.Fatalf("end failed: %+v", res) } } func TestStrictLanguageAndRegion(t *testing.T) { o := NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "c1", AsteriskChannelID: "c1"}) if res := o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "x", Name: tools.SetLanguage, Arguments: map[string]any{"language": "en"}}); res.Error != "invalid_language" { t.Fatalf("expected invalid language, got %+v", res) } if res := o.HandleToolCall(context.Background(), "c1", ai.ToolCall{ID: "x", Name: tools.SetRegion, Arguments: map[string]any{"region_code": "almaty_city"}}); res.Error != "" { t.Fatalf("expected region to be accepted before explicit language, got %+v", res) } } func TestEndCallCleanupAndConcurrency(t *testing.T) { o := NewMemoryOrchestrator(nil) for i := 0; i < 10; i++ { _, _ = o.StartCall(context.Background(), call.CallSession{CallID: string(rune('a' + i)), AsteriskChannelID: "x"}) } var wg sync.WaitGroup for i := 0; i < 10; i++ { id := string(rune('a' + i)) wg.Add(1) go func() { defer wg.Done() _, _ = o.GetSession(id) _ = o.EndCall(context.Background(), id, "test") }() } wg.Wait() if o.Count() != 0 { t.Fatalf("count=%d", o.Count()) } } func TestHandleUserTextLanguageSelection(t *testing.T) { o := NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "l1", AsteriskChannelID: "l1"}) res, err := o.HandleUserText(context.Background(), "l1", "русский") if err != nil || !res.Applied || res.Language != state.LanguageRU || res.State != state.StateReadyToHelp { t.Fatalf("ru selection: res=%+v err=%v", res, err) } o = NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "l2", AsteriskChannelID: "l2"}) res, err = o.HandleUserText(context.Background(), "l2", "қазақша") if err != nil || !res.Applied || res.Language != state.LanguageKK || res.State != state.StateReadyToHelp { t.Fatalf("kk selection: res=%+v err=%v", res, err) } o = NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "l3", AsteriskChannelID: "l3"}) res, _ = o.HandleUserText(context.Background(), "l3", "какой у меня тариф?") if res.Applied || res.State != state.StateReadyToHelp || res.Language != state.LanguageRU { t.Fatalf("business should auto-detect ru without IVR: %+v", res) } res, _ = o.HandleUserText(context.Background(), "l3", "русский или қазақша?") if res.Applied || res.State != state.StateReadyToHelp { t.Fatalf("ambiguous: %+v", res) } } func TestSetLanguageNaturalVariantsAndTranscript(t *testing.T) { o := NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "v1", AsteriskChannelID: "v1"}) for _, value := range []string{"русский", "russian", "қазақша", "kazakh"} { o2 := NewMemoryOrchestrator(nil) _, _ = o2.StartCall(context.Background(), call.CallSession{CallID: value, AsteriskChannelID: value}) res := o2.HandleToolCall(context.Background(), value, ai.ToolCall{ID: "t", Name: tools.SetLanguage, Arguments: map[string]any{"language": value}}) if res.Error != "" { t.Fatalf("%s failed: %+v", value, res) } } bad := o.HandleToolCall(context.Background(), "v1", ai.ToolCall{ID: "bad", Name: tools.SetLanguage, Arguments: map[string]any{"language": "english"}}) if bad.Error != "invalid_language" { t.Fatalf("expected invalid_language, got %+v", bad) } o = NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "tr", AsteriskChannelID: "tr"}) if err := o.HandleVoiceEvent(context.Background(), "tr", ai.VoiceEvent{Type: ai.VoiceEventUserTranscriptDone, Text: "русский"}); err != nil { t.Fatal(err) } s, _ := o.GetSession("tr") if s.Language != state.LanguageRU || s.State != state.StateReadyToHelp { t.Fatalf("transcript not applied: %+v", s) } if err := o.HandleVoiceEvent(context.Background(), "tr", ai.VoiceEvent{Type: ai.VoiceEventAssistantTranscriptDelta, Text: "қазақша"}); err != nil { t.Fatal(err) } s, _ = o.GetSession("tr") if s.Language != state.LanguageRU { t.Fatal("assistant transcript changed language") } } func TestLanguageChangeAfterReadyPreservesRegion(t *testing.T) { o := NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "r", AsteriskChannelID: "r"}) _, _ = o.HandleUserText(context.Background(), "r", "русский") _ = o.HandleToolCall(context.Background(), "r", ai.ToolCall{ID: "reg", Name: tools.SetRegion, Arguments: map[string]any{"region_code": "almaty_city"}}) res, _ := o.HandleUserText(context.Background(), "r", "перейдите на казахский") if !res.Applied || res.Language != state.LanguageKK || res.RegionCode != "almaty_city" || res.State != state.StateReadyToHelp { t.Fatalf("switch failed: %+v", res) } res, _ = o.HandleUserText(context.Background(), "r", "русский клиент спрашивает про тариф") if res.Applied || res.Language != state.LanguageKK { t.Fatalf("accidental switch: %+v", res) } } func TestHandleUserTextRegionSelection(t *testing.T) { o := NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "reg1", AsteriskChannelID: "reg1"}) res, _ := o.HandleUserText(context.Background(), "reg1", "Астана") if !res.Applied || res.State != state.StateReadyToHelp || res.RegionCode != "astana_city" { t.Fatalf("region should be usable without explicit language: %+v", res) } res, _ = o.HandleUserText(context.Background(), "reg1", "русский") if res.State != state.StateReadyToHelp { t.Fatalf("language not selected: %+v", res) } } func TestAlmatyPendingClarification(t *testing.T) { o := NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "alm", AsteriskChannelID: "alm"}) _, _ = o.HandleUserText(context.Background(), "alm", "русский") res, _ := o.HandleUserText(context.Background(), "alm", "Алматы") if res.Applied || !res.NeedsClarification || res.State != state.StateReadyToHelp || res.MessageKey != "region.almaty_clarify" { t.Fatalf("expected almaty clarification: %+v", res) } s, _ := o.GetSession("alm") if s.Region.Status != state.RegionPendingClarification || s.Metadata[state.PendingRegionCandidatesMetadataKey] == "" { t.Fatalf("pending not stored: %+v", s) } res, _ = o.HandleUserText(context.Background(), "alm", "область") if !res.Applied || res.RegionCode != "almaty_region" || res.State != state.StateReadyToHelp { t.Fatalf("oblast clarification failed: %+v", res) } kb := o.HandleToolCall(context.Background(), "alm", ai.ToolCall{ID: "kb", Name: tools.SearchKnowledgeBase}) if kb.Error != "knowledge_base_unavailable" { t.Fatalf("search after region should reach kb unavailable without service: %+v", kb) } } func TestSetRegionNaturalVariantsAndDisabled(t *testing.T) { o := NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "tool-region", AsteriskChannelID: "tool-region"}) _, _ = o.HandleUserText(context.Background(), "tool-region", "русский") res := o.HandleToolCall(context.Background(), "tool-region", ai.ToolCall{ID: "r", Name: tools.SetRegion, Arguments: map[string]any{"region": "Шымкент"}}) if res.Error != "" { t.Fatalf("natural set_region failed: %+v", res) } s, _ := o.GetSession("tool-region") if s.Region.Code != "shymkent_city" || s.State != state.StateReadyToHelp { t.Fatalf("bad region session: %+v", s) } o = NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "disabled-region", AsteriskChannelID: "disabled-region"}) _, _ = o.HandleUserText(context.Background(), "disabled-region", "русский") res = o.HandleToolCall(context.Background(), "disabled-region", ai.ToolCall{ID: "b", Name: tools.SetRegion, Arguments: map[string]any{"region": "Байконур"}}) if res.Error != "disabled_region" { t.Fatalf("expected disabled_region, got %+v", res) } } func TestRegionChangeAfterReadyPreservesLanguage(t *testing.T) { o := NewMemoryOrchestrator(nil) _, _ = o.StartCall(context.Background(), call.CallSession{CallID: "rch", AsteriskChannelID: "rch"}) _, _ = o.HandleUserText(context.Background(), "rch", "русский") _, _ = o.HandleUserText(context.Background(), "rch", "Астана") res, _ := o.HandleUserText(context.Background(), "rch", "сменить регион на Шымкент") if !res.Applied || res.RegionCode != "shymkent_city" || res.Language != state.LanguageRU || res.State != state.StateReadyToHelp { t.Fatalf("switch region failed: %+v", res) } res, _ = o.HandleUserText(context.Background(), "rch", "Алматы тарифы") if res.Applied || res.RegionCode != "shymkent_city" { t.Fatalf("accidental region switch: %+v", res) } }