package main
import (
- "log"
"net/http"
"os"
"path/filepath"
"slices"
+ "strconv"
"strings"
"testing"
"time"
func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) {
server := newModelFake(t)
- server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Hello from the agent.")}, 100, 0, 5))
}
require.Empty(t, telegram.Sent())
require.NotContains(t, terminal.Output(), "secret")
require.NotContains(t, logs.String(), "secret")
- require.Contains(t, logs.String(), "update ignored")
+ require.Contains(t, logs.String(), "<6>message ignored")
}
func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) {
close(release)
})
- server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ server.reply = func(_ http.ResponseWriter, _ ModelFakeCall) {
close(arrived)
<-release
}
})
telegramWaitForSent(t, telegram, "Error: interrupted", "No turn runs.")
+
+ telegram.Add(TelegramUpdate{
+ UpdateID: 4,
+ Message: telegramOwnerMessage(stopCommand + " BlueFox"),
+ })
+
+ telegramWaitForSent(t, telegram, "No turn runs.", "No subagent BlueFox runs.")
}
-func TestBotStartsAtTheStoredOffset(t *testing.T) {
+func TestBotShowsOnlyTheStartAndTheEndOfASubagent(t *testing.T) {
+ telegram := newTelegramFake(t)
server := newModelFake(t)
server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ last := subagentTestLastItem(call)
+ switch {
+ case subagentTestCall(call):
+ // The report comes after the reply of the turn of the spawn.
+ require.Eventually(t, func() bool {
+ return slices.Contains(telegram.Sent(), "Started.")
+ }, 10*time.Second, time.Millisecond)
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
+ case last["type"] == "function_call_output":
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
+ case strings.HasPrefix(subagentTestText(last), "["):
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It counted 3 files.")}, 100, 0, 5))
+ default:
+ modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
+ }
+ }
+
+ bot, _ := telegramStartTestBot(t, telegram)
+ daemonStartTestWithBot(t, server, bot)
+ telegram.Add(TelegramUpdate{
+ UpdateID: 1,
+ Message: telegramOwnerMessage("Count the files in a subagent."),
+ })
+
+ require.Eventually(t, func() bool {
+ return slices.Contains(telegram.Sent(), "It counted 3 files.")
+ }, 10*time.Second, time.Millisecond)
+
+ sent := telegram.Sent()
+ name := strings.TrimSuffix(strings.TrimPrefix(sent[0], "["), "] started")
+ require.True(t, subagentNameValid(name), sent[0])
+ require.Equal(t, []string{
+ "[" + name + "] started",
+ "Started.",
+ "[" + name + "] finished",
+ "It counted 3 files.",
+ }, sent)
+}
+
+func TestBotSkipsTheWorkOfWhatsAppAndTheSchedules(t *testing.T) {
+ bot := &TelegramBot{
+ ready: make(chan struct{}, 1),
+ }
+
+ bot.Notify(Event{
+ Kind: "work",
+ Source: whatsappSource,
+ Text: "[WhatsApp] Ana Souza",
+ })
+ bot.Notify(Event{
+ Kind: "work",
+ Source: scheduleSource,
+ Text: "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once",
+ })
+ require.Empty(t, bot.queue)
+}
+
+func TestBotStartsAtTheStoredOffset(t *testing.T) {
+ server := newModelFake(t)
+ server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
}
terminal.WaitFor(t, "[connected to OneChat]")
telegramWaitForSent(t, telegram, "ok")
- require.Equal(t, float64(10), telegram.Calls()[0].params["offset"])
+ require.InDelta(t, float64(10), telegram.Calls()[0].params["offset"], 0)
require.NotContains(t, terminal.Output(), "handled before the restart")
require.Eventually(t, func() bool {
func TestBotTriesAgainAfterFailures(t *testing.T) {
server := newModelFake(t)
- server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
}
})
telegramWaitForSent(t, telegram, "ok")
- require.Contains(t, logs.String(), "answered 502")
- require.Contains(t, logs.String(), "answered 429")
+ require.Contains(t, logs.String(), "<4>receive failed retry=1ms: 502")
+ require.Contains(t, logs.String(), "<6>receive back after=")
+ require.Contains(t, logs.String(), "<4>send failed retry=1s: 429")
+ require.Contains(t, logs.String(), "<6>send back after=")
require.NotContains(t, logs.String(), telegramTestToken)
}
require.Equal(t, []string{"hello"}, telegram.Sent())
require.Len(t, telegram.Calls(), 2)
- require.Contains(t, logs.String(), "no valid answer")
+ require.Contains(t, logs.String(), "<4>send failed retry=1ms: no answer in 50ms\n")
+ require.Contains(t, logs.String(), "<6>send back after=")
+ require.Equal(t, 2, strings.Count(logs.String(), "\n"))
require.NotContains(t, logs.String(), telegramTestToken)
}
func TestRepliesGoAsRichMessages(t *testing.T) {
reply := "**Done.** The table:\n\n| Name | Size |\n|:-----|-----:|\n| `a.go` | 10 |\n\n- one\n- two\n\n```go\nx := 1\n```"
server := newModelFake(t)
- server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+ server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(reply)}, 100, 0, 5))
}
telegramWaitForSent(t, telegram, telegramTextOnly)
var sends []TelegramFakeCall
+
for _, call := range telegram.Calls() {
if call.method == "sendMessage" || call.method == "sendRichMessage" {
sends = append(sends, call)
always bool
methods []string
sent []string
+ lines int
logText string
}{
{
always: true,
methods: []string{"sendRichMessage", "sendMessage"},
sent: []string{"a secret word"},
- logText: "rich message refused",
+ lines: 1,
+ logText: "<4>reply sent with simple formatting",
},
{
name: "unknown method sends the reply once with entities",
always: true,
methods: []string{"sendRichMessage", "sendMessage"},
sent: []string{"a secret word"},
- logText: "rich message refused",
+ lines: 1,
+ logText: "<4>reply sent with simple formatting",
},
{
name: "rate limit tries the rich message again",
status: http.StatusTooManyRequests,
methods: []string{"sendRichMessage", "sendRichMessage"},
sent: []string{"a **secret** word"},
- logText: "answered 429",
+ lines: 2,
+ logText: "<4>send failed retry=1s: 429",
},
{
name: "server error tries the rich message again",
status: http.StatusInternalServerError,
methods: []string{"sendRichMessage", "sendRichMessage"},
sent: []string{"a **secret** word"},
- logText: "answered 500",
+ lines: 2,
+ logText: "<4>send failed retry=1ms: 500",
},
{
name: "other refusal drops the reply",
status: http.StatusForbidden,
always: true,
methods: []string{"sendRichMessage"},
- logText: "message dropped",
+ lines: 1,
+ logText: "<3>reply lost: 403",
},
}
})
var methods []string
+
for _, call := range telegram.Calls() {
methods = append(methods, call.method)
}
require.Equal(t, tc.methods, methods)
require.Equal(t, tc.sent, telegram.Sent())
- require.Equal(t, 1, strings.Count(logs.String(), "\n"))
+ require.Equal(t, tc.lines, strings.Count(logs.String(), "\n"))
require.Contains(t, logs.String(), tc.logText)
+
+ if tc.lines == 2 {
+ require.Contains(t, logs.String(), "<6>send back after=")
+ }
+
require.NotContains(t, logs.String(), telegramTestToken)
require.NotContains(t, logs.String(), "secret word")
require.Equal(t, []string{paragraphs, table}, telegram.Sent())
}
+func TestReplyTagsGoAsText(t *testing.T) {
+ testCases := []struct {
+ name string
+ status int
+ sent []string
+ }{
+ {
+ name: "rich message escapes the tag outside code",
+ sent: []string{"a <details> & tag and `<b>`"},
+ },
+ {
+ name: "entity fallback shows the tag as written",
+ status: http.StatusBadRequest,
+ sent: []string{"a <details> & tag and <b>"},
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ telegram := newTelegramFake(t)
+ telegram.fail = func(call TelegramFakeCall) (int, string) {
+ if call.method != "sendRichMessage" {
+ return 0, ""
+ }
+
+ return tc.status, "Bad Request"
+ }
+
+ bot, _ := telegramStartTestBot(t, telegram)
+ bot.post(t.Context(), Event{
+ Kind: "reply",
+ Text: "a <details> & tag and `<b>`",
+ })
+
+ require.Equal(t, tc.sent, telegram.Sent())
+ })
+ }
+}
+
+func TestLongReplySplitCountsTheEscapedTags(t *testing.T) {
+ paragraph := "A <b> tag.\n\n"
+ count := telegramRichBytes / len(paragraph)
+ reply := strings.Repeat(paragraph, count)
+ require.LessOrEqual(t, len(reply), telegramRichBytes)
+
+ telegram := newTelegramFake(t)
+ bot, _ := telegramStartTestBot(t, telegram)
+ bot.post(t.Context(), Event{
+ Kind: "reply",
+ Text: reply,
+ })
+
+ sent := telegram.Sent()
+ require.Len(t, sent, 2)
+ require.Equal(t, telegramEscapeTags(reply), strings.Join(sent, ""))
+
+ for _, markdown := range sent {
+ require.LessOrEqual(t, len(markdown), telegramRichBytes)
+ }
+}
+
+func TestRefusedRichPartGoesWithTheTextAsWritten(t *testing.T) {
+ paragraph := "A <b> & c.\n\n"
+ reply := strings.Repeat(paragraph, telegramRichBytes/len(paragraph))
+
+ telegram := newTelegramFake(t)
+ refused := false
+ telegram.fail = func(call TelegramFakeCall) (int, string) {
+ if call.method != "sendRichMessage" || refused {
+ return 0, ""
+ }
+
+ refused = true
+
+ return http.StatusBadRequest, "Bad Request"
+ }
+
+ bot, _ := telegramStartTestBot(t, telegram)
+ bot.post(t.Context(), Event{
+ Kind: "reply",
+ Text: reply,
+ })
+
+ var fallback, rich []string
+
+ for _, call := range telegram.Calls() {
+ switch {
+ case !call.ok:
+ case call.method == "sendMessage":
+ fallback = append(fallback, call.params["text"].(string))
+ case call.method == "sendRichMessage":
+ rich = append(rich, call.params["rich_message"].(map[string]any)["markdown"].(string))
+ }
+ }
+
+ require.Greater(t, len(fallback), 1)
+ require.Len(t, rich, 1)
+ require.True(t, strings.HasPrefix(reply, strings.Join(fallback, "")), "fallback text is not the reply as written")
+
+ for _, text := range fallback {
+ require.LessOrEqual(t, telegramUTF16Units(text), telegramMessageUnits)
+ }
+
+ require.Equal(t, reply, strings.Join(fallback, "")+telegramUnescapeTags(rich[0]), "reply is not sent once and whole")
+}
+
func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
telegram := newTelegramFake(t)
telegram.fail = func(call TelegramFakeCall) (int, string) {
require.Nil(t, calls[1].params["entities"])
require.Equal(t, []string{"a secret word"}, telegram.Sent())
require.Equal(t, 1, strings.Count(logs.String(), "\n"))
- require.Contains(t, logs.String(), "entities refused")
+ require.Contains(t, logs.String(), "<4>reply sent as plain text")
require.NotContains(t, logs.String(), telegramTestToken)
require.NotContains(t, logs.String(), "secret word")
}
+func TestBotLogsALostPlainReplyAsAnError(t *testing.T) {
+ telegram := newTelegramFake(t)
+ telegram.fail = func(call TelegramFakeCall) (int, string) {
+ if call.method == "sendMessage" {
+ return http.StatusForbidden, "Forbidden"
+ }
+
+ return 0, ""
+ }
+
+ bot, logs := telegramStartTestBot(t, telegram)
+ bot.deliver(t.Context(), TelegramPart{text: "secret reply"})
+ require.Equal(t, "<3>reply lost: 403 Forbidden\n", logs.String())
+ require.Empty(t, telegram.Sent())
+}
+
func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
telegram := newTelegramFake(t)
telegram.Close()
daemonStartTestWithBot(t, newModelFake(t), bot)
require.Eventually(t, func() bool {
- return strings.Count(logs.String(), "no valid answer") >= 2
+ return strings.Contains(logs.String(), "<4>receive failed retry=1ms: no answer: ")
}, 10*time.Second, time.Millisecond)
require.NotContains(t, logs.String(), telegramTestToken)
}
+func TestBotLogsAnOutageOnlyAtItsStartAndEnd(t *testing.T) {
+ server := newModelFake(t)
+ server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+ modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
+ }
+
+ telegram := newTelegramFake(t)
+ failures := 5
+ telegram.fail = func(call TelegramFakeCall) (int, string) {
+ if call.method != "getUpdates" || failures == 0 {
+ return 0, ""
+ }
+
+ failures--
+
+ return http.StatusBadGateway, "Bad Gateway"
+ }
+
+ bot, logs := telegramStartTestBot(t, telegram)
+ daemonStartTestWithBot(t, server, bot)
+
+ telegram.Add(TelegramUpdate{
+ UpdateID: 1,
+ Message: telegramOwnerMessage("a secret word"),
+ })
+
+ telegramWaitForSent(t, telegram, "ok")
+
+ lines := strings.Split(strings.TrimSuffix(logs.String(), "\n"), "\n")
+ require.Len(t, lines, 2)
+ require.Equal(t, "<4>receive failed retry=1ms: 502 Bad Gateway", lines[0])
+ require.Regexp(t, `^<6>receive back after=[0-9]+m?s$`, lines[1])
+ require.NotContains(t, logs.String(), "secret word")
+ require.NotContains(t, logs.String(), strconv.FormatInt(telegramTestOwner, 10))
+}
+
func TestSplitMessage(t *testing.T) {
testCases := []struct {
name string
limit: 3,
parts: []string{"ab", "\u00e9c", "d"},
},
+ {
+ name: "long line is not cut inside an entity",
+ markdown: "abc&def",
+ limit: 6,
+ parts: []string{"abc", "&d", "ef"},
+ },
}
for _, tc := range testCases {
t.Helper()
logs := &SyncBuffer{}
- bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), log.New(logs, "", 0))
+ bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), daemonTestLogger(logs))
bot.minWait = time.Millisecond
bot.maxWait = 10 * time.Millisecond