Files
velox-bot/internal/tldr/tldr_test.go
FernandoJVideiraandClaude Opus 5.5 7ae1fa5747 Add /tldr: a private overview of what you missed in a channel
/tldr [since] summarizes the messages in the channel it is run in that the
caller has not been summarized yet, using a small local model on Ollama, and
replies ephemerally. The answer streams into the reply as it is written.

- Per-user, per-channel cursor (tldr_cursors) so each run picks up where the
  last left off; `since:` (e.g. 6h, 2d, max 7d) peeks without moving it.
  First use covers the last 24h, and a stale cursor never reaches back more
  than 7 days.
- Only summarizes channels both the caller and the bot can read.
- The transcript drops bots, system messages, reactions and filler, turns
  links, GIFs, images and files into short tags ([link: site], [image],
  [file: name], preview titles), marks replies, and writes a date marker
  where the chat paused for 3h or more so the model can keep topics apart.
- The prompt asks for one bullet per conversation, oldest first, with a
  worked example; it was tuned against a synthetic chat and a real export
  with qwen3:4b-instruct-2507 (the default model).
- One model call at a time across the bot, one /tldr per user.
- docker-compose: Ollama service (CPU only, 8g memory cap: 6g killed the
  runner mid-request), model pull on startup.

Needs the tldr_cursors migration (velox-dashboard-api 000016).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 18:46:18 +01:00

674 lines
22 KiB
Go

package tldr
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/bwmarrin/discordgo"
)
func TestParseSince(t *testing.T) {
cases := []struct {
in string
want time.Duration
err bool
}{
{"30m", 30 * time.Minute, false},
{"6h", 6 * time.Hour, false},
{"2d", 48 * time.Hour, false},
{" 2D ", 48 * time.Hour, false},
{"30d", maxWindow, false},
{"9999h", maxWindow, false},
{"0h", 0, true},
{"", 0, true},
{"1h30m", 0, true},
{"yesterday", 0, true},
{"-5m", 0, true},
}
for _, c := range cases {
got, err := ParseSince(c.in)
if (err != nil) != c.err {
t.Errorf("ParseSince(%q) err = %v, want err=%v", c.in, err, c.err)
continue
}
if !c.err && got != c.want {
t.Errorf("ParseSince(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestSnowflakeFromTime(t *testing.T) {
// Known pairing: a snowflake's timestamp is (id >> 22) + discord epoch.
ts := time.UnixMilli(1700000000000)
id, err := strconv.ParseInt(SnowflakeFromTime(ts), 10, 64)
if err != nil {
t.Fatal(err)
}
if got := (id >> 22) + discordEpochMs; got != 1700000000000 {
t.Errorf("round trip = %d", got)
}
if SnowflakeFromTime(time.UnixMilli(0)) != "0" {
t.Errorf("pre-epoch time should clamp to 0")
}
}
func TestCompareIDs(t *testing.T) {
// Lexical comparison would get "9" vs "10" wrong.
if CompareIDs("9", "10") >= 0 || CompareIDs("10", "9") <= 0 || CompareIDs("5", "5") != 0 {
t.Error("numeric comparison broken")
}
}
func msg(id, author, content string) *discordgo.Message {
return &discordgo.Message{
ID: id,
Content: content,
Author: &discordgo.User{ID: author, Username: author},
}
}
func TestCondense(t *testing.T) {
bot := msg("1", "bot", "hi")
bot.Author.Bot = true
system := msg("2", "a", "joined")
system.Type = discordgo.MessageTypeGuildMemberJoin
attachOnly := msg("3", "a", "")
attachOnly.Attachments = []*discordgo.MessageAttachment{{}}
withAttach := msg("4", "a", "olha a foto")
withAttach.Attachments = []*discordgo.MessageAttachment{{}}
mention := msg("5", "a", "hey <@42> and <@!42>\n\nlook")
mention.Mentions = []*discordgo.User{{ID: "42", Username: "bob", GlobalName: "Bob"}}
long := msg("6", "a", strings.Repeat("x", 900))
for name, m := range map[string]*discordgo.Message{
"bot": bot,
"system": system,
"blank": msg("7", "a", " \n "),
"attachment only": attachOnly,
"emoji only": msg("8", "a", "👍🔥"),
"custom emoji": msg("9", "a", "<:pog:123456789>"),
"link only": msg("10", "a", "https://example.com/some/very/long/path?x=1"),
"filler ok": msg("11", "a", "Ok!"),
"filler lol": msg("12", "a", "lol"),
"filler kkk": msg("13", "a", "kkkk"),
} {
if _, _, ok := condense(m); ok {
t.Errorf("%s should be dropped", name)
}
}
check := func(m *discordgo.Message, want string) {
t.Helper()
_, got, ok := condense(m)
if !ok || got != want {
t.Errorf("condense(%q) = %q, %v; want %q", m.Content, got, ok, want)
}
}
check(msg("20", "a", "sim"), "sim") // short answers can be votes, keep them
check(msg("21", "a", "olha isto https://www.example.com/x?y=1 fixe"), "olha isto [link: example.com] fixe")
check(msg("22", "a", "vejam <https://youtu.be/abc>."), "vejam [link: youtu.be].")
check(msg("23", "a", "haha https://tenor.com/view/cat-123 mesmo"), "haha [gif] mesmo")
check(msg("24", "a", "foto https://cdn.discordapp.com/a/b/c.PNG?ex=1 ok"), "foto [image] ok")
check(withAttach, "olha a foto [attachment]")
check(mention, "hey @Bob and @Bob look")
_, got, _ := condense(long)
if r := []rune(got); len(r) != maxPerMsgChars+1 {
t.Errorf("long message = %d runes, want %d", len(r), maxPerMsgChars+1)
}
}
func TestSystemPromptHasExample(t *testing.T) {
// The prompt that was benchmarked is the instructions followed by one worked example.
if !strings.HasPrefix(systemPrompt, instructions+"\n\nExample.\nMessages:\n") || !strings.Contains(systemPrompt, "Overview:\n- ") {
t.Error("system prompt should be the instructions plus the worked example")
}
}
func TestCondenseRichContent(t *testing.T) {
check := func(m *discordgo.Message, want string) {
t.Helper()
_, got, ok := condense(m)
if !ok || got != want {
t.Errorf("condense(%q) = %q, %v; want %q", m.Content, got, ok, want)
}
}
dropped := func(name string, m *discordgo.Message) {
t.Helper()
if _, got, ok := condense(m); ok {
t.Errorf("%s should be dropped, got %q", name, got)
}
}
photo := msg("1", "a", "olha")
photo.Attachments = []*discordgo.MessageAttachment{{Filename: "IMG_1.jpg", ContentType: "image/jpeg"}, {Filename: "x.gif"}}
check(photo, "olha [image] [gif]")
photoOnly := msg("2", "a", "")
photoOnly.Attachments = []*discordgo.MessageAttachment{{Filename: "IMG_1.jpg", ContentType: "image/jpeg"}}
dropped("photo only", photoOnly)
doc := msg("3", "a", "")
doc.Attachments = []*discordgo.MessageAttachment{{Filename: "orçamento-setembro.pdf", ContentType: "application/pdf"}}
check(doc, "[file: orçamento-setembro.pdf]")
video := msg("4", "a", "vejam https://youtu.be/abc")
video.Embeds = []*discordgo.MessageEmbed{{Type: discordgo.EmbedTypeVideo, Title: " Como renovar certificados TLS "}}
check(video, `vejam [link: youtu.be] (preview: "Como renovar certificados TLS")`)
linkOnly := msg("5", "a", "https://youtu.be/abc")
linkOnly.Embeds = []*discordgo.MessageEmbed{{Type: discordgo.EmbedTypeVideo, Title: "Tutorial de Grafana"}}
check(linkOnly, `[link: youtu.be] (preview: "Tutorial de Grafana")`)
gif := msg("6", "a", "https://tenor.com/view/cat-123")
gif.Embeds = []*discordgo.MessageEmbed{{Type: discordgo.EmbedTypeGifv, Title: "cat"}}
dropped("gif only", gif)
ref := msg("7", "Ana", "")
ref.Author.GlobalName = "Ana"
reply := msg("8", "Rui", "concordo")
reply.Type = discordgo.MessageTypeReply
reply.ReferencedMessage = ref
check(reply, "(reply to Ana) concordo")
selfReply := msg("9", "Ana", "e mais uma coisa")
selfReply.Type = discordgo.MessageTypeReply
selfReply.ReferencedMessage = ref
check(selfReply, "e mais uma coisa")
deleted := msg("10", "Rui", "ok mas porquê")
deleted.Type = discordgo.MessageTypeReply
check(deleted, "ok mas porquê")
}
func TestBuildTranscriptMergesAuthors(t *testing.T) {
tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "dev", Messages: []*discordgo.Message{
msg("1", "Ana", "primeira"),
msg("2", "Ana", "segunda"),
msg("3", "Rui", "lol"), // filler between two Ana messages is dropped
msg("4", "Ana", "terceira"),
msg("5", "Rui", "resposta"),
}}})
want := "Ana: primeira / segunda / terceira\nRui: resposta\n"
if len(tr.Chunks) != 1 || tr.Chunks[0] != want {
t.Errorf("chunk = %q, want %q", tr.Chunks[0], want)
}
if tr.Messages != 4 || tr.LastProcessed["1"] != "5" {
t.Errorf("messages=%d cursor=%s", tr.Messages, tr.LastProcessed["1"])
}
}
func TestBuildTranscriptCutsLongRunsOfOneAuthor(t *testing.T) {
var msgs []*discordgo.Message
for i := 1; i <= 10; i++ {
msgs = append(msgs, msg(strconv.Itoa(i), "a", strings.Repeat("w", 300)))
}
tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "c", Messages: msgs}})
for _, line := range strings.Split(tr.Chunks[0], "\n") {
if len(line) > maxMergedChars+20 {
t.Errorf("merged line is %d chars, over the %d cap", len(line), maxMergedChars)
}
}
if tr.Messages != 10 {
t.Errorf("messages = %d", tr.Messages)
}
}
func TestDetectLanguage(t *testing.T) {
pt := "o deploy de ontem partiu o login que nao tinha corrido, vou ver para ti mas já está feito. foi uma coisa de pelo menos duas horas, são horas de parar"
en := "the deploy broke the login and that is the thing we saw, you are right that it was the cause of this and it is for the team to fix with the rest"
if got := detectLanguage([]string{pt}); got != "Portuguese" {
t.Errorf("pt = %q", got)
}
if got := detectLanguage([]string{en}); got != "English" {
t.Errorf("en = %q", got)
}
if got := detectLanguage([]string{"ok", "lol"}); got != "" {
t.Errorf("too little text should be unsure, got %q", got)
}
if got := detectLanguage([]string{pt + " " + en}); got != "" {
t.Errorf("mixed text should be unsure, got %q", got)
}
}
func TestSummarizePromptNamesDetectedLanguage(t *testing.T) {
f := &fakeSummarizer{}
pt := strings.Repeat("o deploy de ontem partiu o login que nao tinha corrido, vou ver para ti mas já está feito. ", 3)
if _, err := Summarize(context.Background(), f, []string{pt}, nil, nil); err != nil {
t.Fatal(err)
}
if !strings.Contains(f.users[0], "Write the overview in Portuguese.") {
t.Errorf("user prompt should name Portuguese: %q", f.users[0][len(f.users[0])-80:])
}
f = &fakeSummarizer{}
if _, err := Summarize(context.Background(), f, []string{"ok"}, nil, nil); err != nil {
t.Fatal(err)
}
if !strings.Contains(f.users[0], "same language as the messages") {
t.Error("an unsure guess should fall back to the generic language line")
}
}
func TestBuildTranscriptCursorAndChunks(t *testing.T) {
bot := msg("11", "bot", "ignored")
bot.Author.Bot = true
batches := []ChannelBatch{
{ID: "100", Name: "general", Messages: []*discordgo.Message{msg("10", "a", "one"), bot}},
{ID: "200", Name: "dev", Messages: []*discordgo.Message{msg("20", "b", "two")}},
}
tr := BuildTranscript(batches)
if tr.Messages != 2 || tr.Channels != 2 || tr.Truncated || len(tr.Chunks) != 1 {
t.Fatalf("unexpected transcript: %+v", tr)
}
// The skipped bot message still counts as processed so it never resurfaces.
if tr.LastProcessed["100"] != "11" || tr.LastProcessed["200"] != "20" {
t.Errorf("cursors = %v", tr.LastProcessed)
}
want := "## #general\na: one\n## #dev\nb: two\n"
if tr.Chunks[0] != want {
t.Errorf("chunk = %q, want %q", tr.Chunks[0], want)
}
}
func TestBuildTranscriptSingleChannelHasNoHeaderAndSplitsWithinBudget(t *testing.T) {
var msgs []*discordgo.Message
line := strings.Repeat("y", 400)
for i := 1; i <= 100; i++ {
msgs = append(msgs, msg(strconv.Itoa(i), "a", line))
}
tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "busy", Messages: msgs}})
if len(tr.Chunks) < 2 {
t.Fatalf("expected several chunks, got %d", len(tr.Chunks))
}
for idx, c := range tr.Chunks {
if len(c) > chunkBudgetChars {
t.Errorf("chunk %d is %d chars, over budget", idx, len(c))
}
if strings.Contains(c, "## #") {
t.Errorf("chunk %d has a channel header, a single channel should not", idx)
}
}
if tr.Messages != 100 || tr.Truncated {
t.Errorf("all 100 should fit: %+v", tr)
}
}
func TestBuildTranscriptSeveralChannelsRepeatHeadersPerChunk(t *testing.T) {
var one, two []*discordgo.Message
line := strings.Repeat("y", 400)
for i := 1; i <= 60; i++ {
one = append(one, msg(strconv.Itoa(i), "a", line))
two = append(two, msg(strconv.Itoa(1000+i), "b", line))
}
tr := BuildTranscript([]ChannelBatch{{ID: "1", Name: "one", Messages: one}, {ID: "2", Name: "two", Messages: two}})
if len(tr.Chunks) < 2 {
t.Fatalf("expected several chunks, got %d", len(tr.Chunks))
}
for idx, c := range tr.Chunks {
if !strings.HasPrefix(c, "## #") {
t.Errorf("chunk %d does not start with a channel header", idx)
}
if len(c) > chunkBudgetChars {
t.Errorf("chunk %d is %d chars, over budget", idx, len(c))
}
}
}
func TestBuildTranscriptTruncatesAndHoldsCursor(t *testing.T) {
line := strings.Repeat("z", 480)
var msgs []*discordgo.Message
for i := 1; i <= 400; i++ { // ~190k chars, far over the 90k total budget
msgs = append(msgs, msg(strconv.Itoa(i), "a", line))
}
later := msg("999", "b", "never reached")
tr := BuildTranscript([]ChannelBatch{
{ID: "1", Name: "a", Messages: msgs},
{ID: "2", Name: "b", Messages: []*discordgo.Message{later}},
})
if !tr.Truncated || len(tr.Chunks) > maxChunks {
t.Fatalf("expected truncation within %d chunks: truncated=%v chunks=%d", maxChunks, tr.Truncated, len(tr.Chunks))
}
last := tr.LastProcessed["1"]
if n, _ := strconv.Atoi(last); n != tr.Messages {
t.Errorf("cursor %s should sit exactly at the last included message (%d)", last, tr.Messages)
}
if _, ok := tr.LastProcessed["2"]; ok {
t.Error("a channel after the cut-off must not get a cursor")
}
}
type fakeFetcher struct {
all []*discordgo.Message // ascending
calls int
}
func (f *fakeFetcher) ChannelMessages(_ string, limit int, _, afterID, _ string, _ ...discordgo.RequestOption) ([]*discordgo.Message, error) {
f.calls++
var out []*discordgo.Message
for _, m := range f.all {
if CompareIDs(m.ID, afterID) > 0 {
out = append(out, m)
if len(out) == limit {
break
}
}
}
// Discord hands each page back newest first.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, nil
}
func TestFetchAfterPagesInOrder(t *testing.T) {
f := &fakeFetcher{}
for i := 1; i <= 250; i++ {
f.all = append(f.all, msg(strconv.Itoa(i), "a", "m"))
}
got, err := FetchAfter(f, "c", "0", 1000)
if err != nil || len(got) != 250 || f.calls != 3 {
t.Fatalf("len=%d calls=%d err=%v", len(got), f.calls, err)
}
for i, m := range got {
if m.ID != strconv.Itoa(i+1) {
t.Fatalf("out of order at %d: %s", i, m.ID)
}
}
f.calls = 0
got, _ = FetchAfter(f, "c", "0", 120)
if len(got) != 120 || got[119].ID != "120" || f.calls != 2 {
t.Errorf("limit not honoured: len=%d last=%s calls=%d", len(got), got[len(got)-1].ID, f.calls)
}
}
type fakeSummarizer struct {
mu sync.Mutex
calls []string
users []string
err error
}
func (f *fakeSummarizer) Chat(_ context.Context, system, user string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, system)
f.users = append(f.users, user)
if f.err != nil {
return "", f.err
}
return fmt.Sprintf("summary-%d", len(f.calls)), nil
}
func TestSummarizeSingleAndMerge(t *testing.T) {
f := &fakeSummarizer{}
out, err := Summarize(context.Background(), f, []string{"a"}, nil, nil)
if err != nil || out != "summary-1" || len(f.calls) != 1 {
t.Fatalf("single chunk: %q %v calls=%d", out, err, len(f.calls))
}
f = &fakeSummarizer{}
var steps [][2]int
out, err = Summarize(context.Background(), f, []string{"a", "b", "c"}, func(done, total int) {
steps = append(steps, [2]int{done, total})
}, nil)
if err != nil || len(f.calls) != 4 || out != "summary-4" {
t.Fatalf("map-reduce: %q %v calls=%d", out, err, len(f.calls))
}
if f.calls[3] != mergePrompt || f.calls[0] != systemPrompt {
t.Error("final call should use the merge prompt, the rest the summary prompt")
}
if len(steps) != 4 || steps[0] != [2]int{0, 4} || steps[3] != [2]int{3, 4} {
t.Errorf("progress = %v", steps)
}
if _, err := Summarize(context.Background(), &fakeSummarizer{err: errors.New("boom")}, []string{"a"}, nil, nil); err == nil {
t.Error("model errors must propagate")
}
if _, err := Summarize(context.Background(), f, nil, nil, nil); err == nil {
t.Error("empty input must error")
}
}
const readPerms = int64(discordgo.PermissionViewChannel | discordgo.PermissionReadMessageHistory)
type fakeDiscord struct {
*fakeFetcher
botPerms int64
fetched map[string]string // channel ID -> the "after" it was first fetched with
fetchErr error
}
func newFakeDiscord(msgs ...*discordgo.Message) *fakeDiscord {
return &fakeDiscord{fakeFetcher: &fakeFetcher{all: msgs}, botPerms: readPerms, fetched: map[string]string{}}
}
func (d *fakeDiscord) ChannelMessages(ch string, limit int, b, after, a string, o ...discordgo.RequestOption) ([]*discordgo.Message, error) {
if _, seen := d.fetched[ch]; !seen {
d.fetched[ch] = after
}
if d.fetchErr != nil {
return nil, d.fetchErr
}
return d.fakeFetcher.ChannelMessages(ch, limit, b, after, a, o...)
}
func (d *fakeDiscord) UserChannelPermissions(string, string, ...discordgo.RequestOption) (int64, error) {
return d.botPerms, nil
}
type fakeStore struct {
cursors map[int64]int64
saved map[int64]int64
}
func (s *fakeStore) GetCursors(context.Context, int64, int64) (map[int64]int64, error) {
return s.cursors, nil
}
func (s *fakeStore) SetCursors(_ context.Context, _, _ int64, c map[int64]int64) error {
s.saved = c
return nil
}
func req(since time.Duration, now time.Time) Request {
return Request{GuildID: "1", ChannelID: "10", UserID: "7", BotID: "bot", UserPerms: readPerms, Since: since, Now: now}
}
func TestRunSummarizesOnlyTheRequestedChannel(t *testing.T) {
now := time.Now()
oldID := SnowflakeFromTime(now.Add(-30 * 24 * time.Hour)) // far older than the 7d floor
recent, _ := strconv.ParseInt(SnowflakeFromTime(now.Add(-time.Hour)), 10, 64)
d := newFakeDiscord(
msg(strconv.FormatInt(recent+1, 10), "a", "deploy is broken"),
msg(strconv.FormatInt(recent+2, 10), "b", "fixing it now"),
)
staleCursor, _ := strconv.ParseInt(oldID, 10, 64)
// The user also has a cursor in another channel, which must be left alone.
store := &fakeStore{cursors: map[int64]int64{10: staleCursor, 99: 12345}}
sum := &fakeSummarizer{}
res, err := Run(context.Background(), d, store, sum, req(0, now), nil, nil)
if err != nil {
t.Fatal(err)
}
if res.Empty || res.Messages != 2 || res.Summary != "summary-1" {
t.Fatalf("result = %+v", res)
}
if len(d.fetched) != 1 {
t.Errorf("fetched channels = %v, want only channel 10", d.fetched)
}
if got := d.fetched["10"]; CompareIDs(got, SnowflakeFromTime(now.Add(-maxWindow))) < 0 {
t.Errorf("stale cursor reached back past the 7-day floor: %s", got)
}
if len(store.saved) != 1 || store.saved[10] != recent+2 {
t.Errorf("cursors saved = %v, want only channel 10 -> %d", store.saved, recent+2)
}
if strings.Contains(sum.users[0], "## #") {
t.Error("a single-channel prompt should carry no channel header")
}
}
func TestRunRefusesWhenTheUserCannotReadTheChannel(t *testing.T) {
d := newFakeDiscord(msg("1", "a", "secret plans"))
sum := &fakeSummarizer{}
r := req(0, time.Now())
r.UserPerms = discordgo.PermissionViewChannel // can see the channel but not its history
_, err := Run(context.Background(), d, &fakeStore{}, sum, r, nil, nil)
if !errors.Is(err, ErrUserCannotRead) {
t.Fatalf("err = %v, want ErrUserCannotRead", err)
}
if len(d.fetched) != 0 || len(sum.calls) != 0 {
t.Errorf("nothing should be fetched or summarized: fetched=%v calls=%d", d.fetched, len(sum.calls))
}
}
func TestRunRefusesWhenTheBotCannotReadTheChannel(t *testing.T) {
d := newFakeDiscord(msg("1", "a", "hello"))
d.botPerms = discordgo.PermissionViewChannel
_, err := Run(context.Background(), d, &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil)
if !errors.Is(err, ErrBotCannotRead) || len(d.fetched) != 0 {
t.Errorf("err=%v fetched=%v", err, d.fetched)
}
}
func TestRunMapsADiscord403ToBotCannotRead(t *testing.T) {
d := newFakeDiscord()
d.fetchErr = &discordgo.RESTError{Response: &http.Response{StatusCode: http.StatusForbidden}}
_, err := Run(context.Background(), d, &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil)
if !errors.Is(err, ErrBotCannotRead) {
t.Errorf("err = %v, want ErrBotCannotRead", err)
}
d.fetchErr = errors.New("network down")
if _, err := Run(context.Background(), d, &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil); err == nil || errors.Is(err, ErrBotCannotRead) {
t.Errorf("other fetch errors must not look like a permission problem: %v", err)
}
}
func TestRunSinceModeLeavesCursorsAlone(t *testing.T) {
now := time.Now()
recent, _ := strconv.ParseInt(SnowflakeFromTime(now.Add(-time.Minute)), 10, 64)
d := newFakeDiscord(msg(strconv.FormatInt(recent+1, 10), "a", "hello"))
store := &fakeStore{}
res, err := Run(context.Background(), d, store, &fakeSummarizer{}, req(time.Hour, now), nil, nil)
if err != nil || res.Empty {
t.Fatalf("res=%+v err=%v", res, err)
}
if store.saved != nil {
t.Errorf("since mode must not move cursors, saved %v", store.saved)
}
}
func TestRunNothingNew(t *testing.T) {
res, err := Run(context.Background(), newFakeDiscord(), &fakeStore{}, &fakeSummarizer{}, req(0, time.Now()), nil, nil)
if err != nil || !res.Empty {
t.Fatalf("res=%+v err=%v", res, err)
}
}
// fakeStreamer records whether each call went through Chat or ChatStream.
type fakeStreamer struct {
mu sync.Mutex
log []string
}
func (f *fakeStreamer) Chat(context.Context, string, string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.log = append(f.log, "chat")
return "partial", nil
}
func (f *fakeStreamer) ChatStream(_ context.Context, _, _ string, onText func(string)) (string, error) {
f.mu.Lock()
f.log = append(f.log, "stream")
f.mu.Unlock()
for _, s := range []string{"he", "hello", "hello world"} {
if onText != nil {
onText(s)
}
}
return "hello world", nil
}
func TestSummarizeStreamsTheSingleChunk(t *testing.T) {
f := &fakeStreamer{}
var seen []string
out, err := Summarize(context.Background(), f, []string{"a"}, nil, func(s string) { seen = append(seen, s) })
if err != nil || out != "hello world" {
t.Fatalf("out=%q err=%v", out, err)
}
if strings.Join(f.log, ",") != "stream" || len(seen) != 3 || seen[2] != "hello world" {
t.Errorf("log=%v seen=%v", f.log, seen)
}
}
func TestSummarizeStreamsOnlyTheFinalMerge(t *testing.T) {
f := &fakeStreamer{}
var seen []string
out, err := Summarize(context.Background(), f, []string{"a", "b", "c"}, nil, func(s string) { seen = append(seen, s) })
if err != nil || out != "hello world" {
t.Fatalf("out=%q err=%v", out, err)
}
// The three per-chunk passes are intermediate notes and must not stream.
if strings.Join(f.log, ",") != "chat,chat,chat,stream" || len(seen) != 3 {
t.Errorf("log=%v seen=%v", f.log, seen)
}
}
func TestSummarizeWithoutPartialUsesPlainChat(t *testing.T) {
f := &fakeStreamer{}
out, err := Summarize(context.Background(), f, []string{"a"}, nil, nil)
if err != nil || out != "partial" || strings.Join(f.log, ",") != "chat" {
t.Errorf("out=%q err=%v log=%v", out, err, f.log)
}
}
func TestSummarizeFallsBackWhenSummarizerCannotStream(t *testing.T) {
called := false
out, err := Summarize(context.Background(), &fakeSummarizer{}, []string{"a"}, nil, func(string) { called = true })
if err != nil || out != "summary-1" || called {
t.Errorf("out=%q err=%v partialCalled=%v", out, err, called)
}
}
func TestThrottle(t *testing.T) {
clock := time.Unix(1000, 0)
now := func() time.Time { return clock }
var got []string
f := throttle(2*time.Second, now, func(s string) { got = append(got, s) })
f("a") // first call always passes
clock = clock.Add(time.Second)
f("b") // too soon
clock = clock.Add(time.Second)
f("c") // 2s since the last edit
clock = clock.Add(500 * time.Millisecond)
f("d") // too soon again
if strings.Join(got, "") != "ac" {
t.Errorf("edits = %v, want [a c]", got)
}
if throttle(time.Second, now, nil) != nil {
t.Error("a nil callback should stay nil")
}
}