package main import ( "context" "testing" "github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/voice" ) // The phrasing itself is tested in internal/phraser. What is left here is the // only thing the daemon adds: the stub floor, on the three ways a reply can // fail to arrive. type stubCompleter struct { out string err error } func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return s.out, s.err } func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) { r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil) got := r.Reply(context.Background(), router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) if got != "записала, кофе закончился" { t.Errorf("got %q, want %q", got, "записала, кофе закончился") } } func TestLLMReplierFallsBackToStubOnError(t *testing.T) { r := newLLMReplier(stubCompleter{err: errReplierTest}, nil) assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error") } func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) { r := newLLMReplier(stubCompleter{out: ""}, nil) assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm") } // A clarify never reaches the model, and since Vikunja #457 it is answered from // the clarify deck rather than the stub's single sentence. func TestLLMReplierClarifyReadsTheDeck(t *testing.T) { r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil) got := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "мгм"}) if got == "я всё поняла" { t.Fatal("a clarify must not be phrased by the model") } if want := clarifyMissedFor("мгм"); got != want { t.Errorf("on clarify: got %q, want %q", got, want) } // Two different misses do not sound identical. if same := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "а"}); same == got { t.Log("two utterances hashed to the same line, which is allowed but should be rare") } } // assertAck — the stub picks between variants now, so two calls to it are not // expected to match. What must hold is that the reply is a line that entry can // produce, which is the same claim without pinning one wording. func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) { t.Helper() if got := r.Reply(context.Background(), d); !phraser.IsAck(key, nil, got) { t.Errorf("on %s: got %q, want a %q line", what, got, key) } } func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) { t.Helper() got, want := r.Reply(context.Background(), d), voice.NewStubReplier().Reply(context.Background(), d) if got != want { t.Errorf("on %s: got %q, want stub %q", what, got, want) } } var errReplierTest = errTest("llm down") type errTest string func (e errTest) Error() string { return string(e) }