package ai import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" ) func TestLLMPassesExplicitZeroAndDoesNotRetry(t *testing.T) { task, providers := currentFixture(t, "full_ai") calls := 0 var captured map[string]any var authorization string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ authorization = r.Header.Get("Authorization") if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/chat/completions") { http.Error(w, "unexpected LLM request", http.StatusBadRequest) return } if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { http.Error(w, "invalid LLM request", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprintln(w, `{"id":"mock","object":"chat.completion","created":0,"model":"example-chat","choices":[{"index":0,"message":{"role":"assistant","content":"收到"},"finish_reason":"stop"}]}`) })) defer server.Close() p := providers["openai_compatible"] p.Endpoint = server.URL + "/v1" providers[p.Code] = p bound, err := Bind(task, providers) if err != nil { t.Fatal(err) } reply, err := bound.Complete(context.Background(), "用户说话") if err != nil || reply != "收到" || calls != 1 || authorization != "Bearer "+p.Credential { t.Fatalf("LLM SDK result=%q requests=%d err=%v", reply, calls, err) } if captured["model"] != "example-chat" || captured["temperature"] != float64(0) || captured["max_tokens"] != float64(256) { t.Fatalf("LLM business values were not transmitted: %v", captured) } messages := captured["messages"].([]any) if len(messages) != 2 || messages[0].(map[string]any)["content"] != "Example only" || messages[1].(map[string]any)["content"] != "用户说话" { t.Fatal("immutable prompt and final user text must reach LLM") } } func TestLLMFailureAndEmptyChoicesAreNotRetried(t *testing.T) { for _, tc := range []struct { name string code int body string }{ {name: "provider failure", code: http.StatusInternalServerError, body: `{"error":{"message":"injected failure","type":"server_error"}}`}, {name: "empty choices", code: http.StatusOK, body: `{"id":"mock","object":"chat.completion","created":0,"model":"example-chat","choices":[]}`}, } { t.Run(tc.name, func(t *testing.T) { task, providers := currentFixture(t, "full_ai") calls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls++ w.Header().Set("Content-Type", "application/json") w.WriteHeader(tc.code) _, _ = fmt.Fprintln(w, tc.body) })) defer server.Close() provider := providers["openai_compatible"] provider.Endpoint = server.URL + "/v1" providers[provider.Code] = provider bound, err := Bind(task, providers) if err != nil { t.Fatal(err) } if reply, err := bound.Complete(context.Background(), "用户说话"); err == nil || calls != 1 || reply != "" { t.Fatalf("LLM failure must be visible without retry: reply=%q calls=%d err=%v", reply, calls, err) } }) } } func TestKeywordHangupUsesFinalUserTextOnly(t *testing.T) { task, providers := currentFixture(t, "full_ai") bound, err := Bind(task, providers) if err != nil { t.Fatal(err) } calls := 0 call, err := NewCall(bound, func(context.Context) error { calls++; return nil }) if err != nil { t.Fatal(err) } for _, segment := range []ASRSegment{ {Source: "user", Text: "不用了", Final: false}, {Source: "assistant", Text: "不用了", Final: true}, {Source: "user", Text: "继续说", Final: true}, } { stop, err := call.HandleFinalASR(context.Background(), segment) if err != nil || stop.EndedByKeyword || calls != 0 { t.Fatalf("interim/assistant/unmatched text cannot hang up: stopped=%t err=%v calls=%d", stop.EndedByKeyword, err, calls) } } // A missing TTS is an explicit closing failure, not permission to call LLM // or silently drop the keyword fact. No provider request is made here. call.bound.TTS = nil stop, err := call.HandleFinalASR(context.Background(), ASRSegment{Source: "user", Text: "我不用了", Final: true}) if err == nil || !stop.EndedByKeyword || stop.Transcript != "我不用了" || stop.Reply != "好的,祝您生活愉快。" || calls != 0 { t.Fatalf("closing failure lost keyword fact or hung up before playback: turn=%+v calls=%d err=%v", stop, calls, err) } if err := call.FinishKeyword(context.Background()); err != nil || calls != 1 { t.Fatalf("closing failure cleanup: calls=%d err=%v", calls, err) } if _, err := call.HandleFinalASR(context.Background(), ASRSegment{Source: "user", Text: "我不用了", Final: true}); err == nil { t.Fatal("closing failure was replayed") } if _, err := call.RunTurn(context.Background(), nil); err == nil { t.Fatal("new ASR turn started after keyword closing") } } func TestASRRequiresFinalResult(t *testing.T) { var text finalASRAccumulator text.Add("不用了", false) if _, err := text.Result(); err == nil { t.Fatal("interim-only transcript must never trigger keyword hangup or LLM") } text.Add("继续", true) text.Add("不用了", false) result, err := text.Result() if err != nil || result != "继续" { t.Fatalf("only the first final user result may be used: %q %v", result, err) } }