package phraser import ( "context" "errors" "strings" "testing" "github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/loop" ) // fakeRemote — a workstation model that is up or down on command, and records // what it was asked. type fakeRemote struct { up bool reply string err error got []llm.Req } func (f *fakeRemote) Available() bool { return f.up } func (f *fakeRemote) CompleteRemote(_ context.Context, r llm.Req) (string, error) { f.got = append(f.got, r) if f.err != nil { return "", f.err } return f.reply, nil } // The three outcomes of the naming half, in one place. The middle one is the // whole task: a gap he is told about, not an answer from the smaller model. func TestPhraseWorldNamesTheGapOnlyWhenThereIsOne(t *testing.T) { answer := `{"response": "Небо голубое из-за рэлеевского рассеяния.", "mood": "neutral"}` t.Run("no workstation configured: the resident model answers as today", func(t *testing.T) { spy := newPromptSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{}) got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil) if err != nil { t.Fatalf("PhraseWorld: %v", err) } if got == "" { t.Fatal("no reply from the resident model") } if len(spy.user) != 1 { t.Fatalf("resident model saw %d requests, want 1", len(spy.user)) } }) t.Run("workstation up: it answers and the resident model is not asked", func(t *testing.T) { spy := newPromptSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{}) remote := &fakeRemote{up: true, reply: answer} p.UseRemote(remote) got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil) if err != nil { t.Fatalf("PhraseWorld: %v", err) } if !strings.Contains(got, "рассеяния") { t.Errorf("reply is not the workstation's: %q", got) } if len(spy.user) != 0 { t.Errorf("the resident model was asked %d times, want 0", len(spy.user)) } }) t.Run("workstation down: the gap, and nothing invented", func(t *testing.T) { spy := newPromptSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{}) p.UseRemote(&fakeRemote{up: false}) got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil) if !errors.Is(err, ErrNoWorldModel) { t.Fatalf("err = %v, want ErrNoWorldModel", err) } if got != "" { t.Errorf("got a reply %q with no world model", got) } if len(spy.user) != 0 { t.Errorf("the resident model answered a world question %d times, want 0", len(spy.user)) } }) t.Run("workstation errors mid-request: still the gap", func(t *testing.T) { spy := newPromptSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{}) p.UseRemote(&fakeRemote{up: true, err: errors.New("connection refused")}) if _, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil); !errors.Is(err, ErrNoWorldModel) { t.Fatalf("err = %v, want ErrNoWorldModel", err) } if len(spy.user) != 0 { t.Errorf("the resident model answered a world question %d times, want 0", len(spy.user)) } }) } // Prompt parity: the workstation model is asked the same question in the same // words, or the fixtures measure one thing and the daemon ships another. func TestPhraseWorldSendsTheSamePromptsAsPhraseQuery(t *testing.T) { spy := newPromptSpy(t) resident := NewLLMPhraserAt(spy.srv.URL, Config{}) if _, err := resident.PhraseQuery(context.Background(), "кто написал войну и мир", []string{"Лев Толстой"}); err != nil { t.Fatal(err) } remote := &fakeRemote{up: true, reply: `{"response": "Толстой.", "mood": "neutral"}`} offloaded := NewLLMPhraserAt(spy.srv.URL, Config{}) offloaded.UseRemote(remote) if _, err := offloaded.PhraseWorld(context.Background(), "кто написал войну и мир", []string{"Лев Толстой"}); err != nil { t.Fatal(err) } if len(remote.got) != 1 { t.Fatalf("the workstation saw %d requests, want 1", len(remote.got)) } if remote.got[0].System != spy.system[0] { t.Errorf("system prompts differ:\nremote: %q\nresident: %q", remote.got[0].System, spy.system[0]) } if remote.got[0].User != spy.user[0] { t.Errorf("user prompts differ:\nremote: %q\nresident: %q", remote.got[0].User, spy.user[0]) } } // The silent half. A nudge phrased on the workstation is not news, and one // phrased here because the card is busy is not news either — but it must be // sampled the same way, or the workstation quietly changes how she sounds. func TestNudgePhrasingPrefersTheWorkstationSilently(t *testing.T) { spy := newPromptSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true}) remote := &fakeRemote{up: true, reply: `{"response": "Выпей воды.", "mood": "neutral"}`} p.UseRemote(remote) pn, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}) if err != nil { t.Fatalf("PhraseNudge: %v", err) } if pn.Body != "Выпей воды." { t.Errorf("body = %q, want the workstation's wording", pn.Body) } if len(remote.got) != 1 { t.Fatalf("the workstation saw %d requests, want 1", len(remote.got)) } if remote.got[0].Temperature != defaultChatTemperature { t.Errorf("temperature = %v, want %v (what the resident transport samples at)", remote.got[0].Temperature, defaultChatTemperature) } if len(spy.user) != 0 { t.Errorf("the resident model phrased %d nudges, want 0", len(spy.user)) } } func TestNudgePhrasingFallsBackWhenTheCardIsBusy(t *testing.T) { spy := newPromptSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true}) p.UseRemote(&fakeRemote{up: false}) if _, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}); err != nil { t.Fatalf("PhraseNudge: %v", err) } if len(spy.user) != 1 { t.Fatalf("the resident model phrased %d nudges, want 1", len(spy.user)) } }