13 "github.com/stretchr/testify/require"
16 func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) {
17 server := newModelFake(t)
18 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
19 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Hello from the agent.")}, 100, 0, 5))
22 telegram := newTelegramFake(t)
23 bot, _ := telegramStartTestBot(t, telegram)
24 socket := daemonStartTestWithBot(t, server, bot)
25 terminal := terminalStartTestClient(t, socket)
26 terminal.WaitFor(t, "[connected to OneChat]")
28 telegram.Add(TelegramUpdate{
30 Message: telegramOwnerMessage("hi from the phone"),
33 terminal.WaitFor(t, "[telegram] hi from the phone\n[working]\nHello from the agent.\n[done]")
34 telegramWaitForSent(t, telegram, "Hello from the agent.")
35 require.True(t, slices.ContainsFunc(telegram.Calls(), func(call TelegramFakeCall) bool {
36 return call.method == "sendChatAction" && call.params["action"] == "typing"
39 terminal.Type("hi from the desk")
40 require.Equal(t, []string{"Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent."}, telegramWaitForSent(t, telegram, "Hello from the agent.", "[terminal] hi from the desk", "Hello from the agent."))
43 func TestBotIgnoresEveryoneButTheOwner(t *testing.T) {
44 telegram := newTelegramFake(t)
45 bot, logs := telegramStartTestBot(t, telegram)
46 socket := daemonStartTestWithBot(t, newModelFake(t), bot)
47 terminal := terminalStartTestClient(t, socket)
48 terminal.WaitFor(t, "[connected to OneChat]")
50 group := telegramOwnerMessage("secret in a group")
51 group.Chat = TelegramChat{
56 stranger := telegramOwnerMessage("secret of a stranger")
60 unsigned := telegramOwnerMessage("secret with no sender")
63 for id, message := range []*TelegramMessage{group, stranger, unsigned, nil} {
64 telegram.Add(TelegramUpdate{
65 UpdateID: int64(10 + id),
70 require.Eventually(t, func() bool {
71 offset, err := telegramLoadOffset(bot.offsetPath)
72 return err == nil && offset == 14
73 }, 10*time.Second, time.Millisecond)
75 require.Empty(t, telegram.Sent())
76 require.NotContains(t, terminal.Output(), "secret")
77 require.NotContains(t, logs.String(), "secret")
78 require.Contains(t, logs.String(), "update ignored")
81 func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) {
82 telegram := newTelegramFake(t)
83 bot, _ := telegramStartTestBot(t, telegram)
84 daemonStartTestWithBot(t, newModelFake(t), bot)
86 telegram.Add(TelegramUpdate{
88 Message: telegramOwnerMessage(""),
91 telegramWaitForSent(t, telegram, telegramTextOnly)
94 func TestStopFromTelegramInterruptsTheTurn(t *testing.T) {
95 server := newModelFake(t)
96 arrived := make(chan struct{})
97 release := make(chan struct{})
102 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
107 telegram := newTelegramFake(t)
108 bot, _ := telegramStartTestBot(t, telegram)
109 daemonStartTestWithBot(t, server, bot)
111 telegram.Add(TelegramUpdate{
113 Message: telegramOwnerMessage("a long task"),
117 telegram.Add(TelegramUpdate{
119 Message: telegramOwnerMessage(stopCommand),
122 telegramWaitForSent(t, telegram, "Error: interrupted")
124 telegram.Add(TelegramUpdate{
126 Message: telegramOwnerMessage(stopCommand),
129 telegramWaitForSent(t, telegram, "Error: interrupted", "No turn runs.")
131 telegram.Add(TelegramUpdate{
133 Message: telegramOwnerMessage(stopCommand + " BlueFox"),
136 telegramWaitForSent(t, telegram, "No turn runs.", "No subagent BlueFox runs.")
139 func TestBotShowsOnlyTheStartAndTheEndOfASubagent(t *testing.T) {
140 telegram := newTelegramFake(t)
141 server := newModelFake(t)
142 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
143 last := subagentTestLastItem(call)
145 case subagentTestCall(call):
146 // The report comes after the reply of the turn of the spawn.
147 require.Eventually(t, func() bool {
148 return slices.Contains(telegram.Sent(), "Started.")
149 }, 10*time.Second, time.Millisecond)
150 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("There are 3 files.")}, 100, 0, 5))
151 case last["type"] == "function_call_output":
152 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Started.")}, 100, 0, 5))
153 case strings.HasPrefix(subagentTestText(last), "["):
154 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It counted 3 files.")}, 100, 0, 5))
156 modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "spawn", `{"tasks":["Count the files."],"effort":null}`)}, 100, 0, 5))
160 bot, _ := telegramStartTestBot(t, telegram)
161 daemonStartTestWithBot(t, server, bot)
162 telegram.Add(TelegramUpdate{
164 Message: telegramOwnerMessage("Count the files in a subagent."),
167 require.Eventually(t, func() bool {
168 return slices.Contains(telegram.Sent(), "It counted 3 files.")
169 }, 10*time.Second, time.Millisecond)
171 sent := telegram.Sent()
172 name := strings.TrimSuffix(strings.TrimPrefix(sent[0], "["), "] started")
173 require.True(t, subagentNameValid(name), sent[0])
174 require.Equal(t, []string{
175 "[" + name + "] started",
177 "[" + name + "] finished",
178 "It counted 3 files.",
182 func TestBotStartsAtTheStoredOffset(t *testing.T) {
183 server := newModelFake(t)
184 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
185 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
188 telegram := newTelegramFake(t)
189 bot, _ := telegramStartTestBot(t, telegram)
190 require.NoError(t, telegramSaveOffset(bot.offsetPath, 10))
192 telegram.Add(TelegramUpdate{
194 Message: telegramOwnerMessage("handled before the restart"),
197 telegram.Add(TelegramUpdate{
199 Message: telegramOwnerMessage("new after the restart"),
202 socket := daemonStartTestWithBot(t, server, bot)
203 terminal := terminalStartTestClient(t, socket)
204 terminal.WaitFor(t, "[connected to OneChat]")
205 telegramWaitForSent(t, telegram, "ok")
207 require.Equal(t, float64(10), telegram.Calls()[0].params["offset"])
208 require.NotContains(t, terminal.Output(), "handled before the restart")
210 require.Eventually(t, func() bool {
211 offset, err := telegramLoadOffset(bot.offsetPath)
212 return err == nil && offset == 11
213 }, 10*time.Second, time.Millisecond)
216 func TestBotTriesAgainAfterFailures(t *testing.T) {
217 server := newModelFake(t)
218 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
219 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
222 telegram := newTelegramFake(t)
223 failures := map[string]int{
225 "sendRichMessage": 1,
228 telegram.fail = func(call TelegramFakeCall) (int, string) {
229 if failures[call.method] == 0 {
233 failures[call.method]--
234 if call.method == "sendRichMessage" {
235 return http.StatusTooManyRequests, "Too Many Requests: retry after 1"
238 return http.StatusBadGateway, "Bad Gateway"
241 bot, logs := telegramStartTestBot(t, telegram)
242 daemonStartTestWithBot(t, server, bot)
244 telegram.Add(TelegramUpdate{
246 Message: telegramOwnerMessage("hello"),
249 telegramWaitForSent(t, telegram, "ok")
250 require.Contains(t, logs.String(), "answered 502")
251 require.Contains(t, logs.String(), "answered 429")
252 require.NotContains(t, logs.String(), telegramTestToken)
255 func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
256 telegram := newTelegramFake(t)
258 telegram.stall = func(call TelegramFakeCall) bool {
259 if call.method != "sendMessage" || stalls == 0 {
268 bot, logs := telegramStartTestBot(t, telegram)
269 bot.api.sendTimeout = 50 * time.Millisecond
270 bot.deliver(t.Context(), TelegramPart{
274 require.Equal(t, []string{"hello"}, telegram.Sent())
275 require.Len(t, telegram.Calls(), 2)
276 require.Contains(t, logs.String(), "no valid answer")
277 require.NotContains(t, logs.String(), telegramTestToken)
280 func TestRepliesGoAsRichMessages(t *testing.T) {
281 reply := "**Done.** The table:\n\n| Name | Size |\n|:-----|-----:|\n| `a.go` | 10 |\n\n- one\n- two\n\n```go\nx := 1\n```"
282 server := newModelFake(t)
283 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
284 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(reply)}, 100, 0, 5))
287 telegram := newTelegramFake(t)
288 bot, _ := telegramStartTestBot(t, telegram)
289 socket := daemonStartTestWithBot(t, server, bot)
290 terminal := terminalStartTestClient(t, socket)
291 terminal.WaitFor(t, "[connected to OneChat]")
293 terminal.Type("make **it** so")
294 telegramWaitForSent(t, telegram, "[terminal] make **it** so", reply)
296 telegram.Add(TelegramUpdate{
298 Message: telegramOwnerMessage(""),
301 telegramWaitForSent(t, telegram, telegramTextOnly)
303 var sends []TelegramFakeCall
304 for _, call := range telegram.Calls() {
305 if call.method == "sendMessage" || call.method == "sendRichMessage" {
306 sends = append(sends, call)
310 require.Equal(t, []TelegramFakeCall{
312 method: "sendMessage",
313 params: map[string]any{
314 "chat_id": float64(telegramTestOwner),
315 "text": "[terminal] make **it** so",
320 method: "sendRichMessage",
321 params: map[string]any{
322 "chat_id": float64(telegramTestOwner),
323 "rich_message": map[string]any{
330 method: "sendMessage",
331 params: map[string]any{
332 "chat_id": float64(telegramTestOwner),
333 "text": telegramTextOnly,
340 func TestRichMessageFailures(t *testing.T) {
341 testCases := []struct {
350 name: "bad request sends the reply once with entities",
351 status: http.StatusBadRequest,
353 methods: []string{"sendRichMessage", "sendMessage"},
354 sent: []string{"a secret word"},
355 logText: "rich message refused",
358 name: "unknown method sends the reply once with entities",
359 status: http.StatusNotFound,
361 methods: []string{"sendRichMessage", "sendMessage"},
362 sent: []string{"a secret word"},
363 logText: "rich message refused",
366 name: "rate limit tries the rich message again",
367 status: http.StatusTooManyRequests,
368 methods: []string{"sendRichMessage", "sendRichMessage"},
369 sent: []string{"a **secret** word"},
370 logText: "answered 429",
373 name: "server error tries the rich message again",
374 status: http.StatusInternalServerError,
375 methods: []string{"sendRichMessage", "sendRichMessage"},
376 sent: []string{"a **secret** word"},
377 logText: "answered 500",
380 name: "other refusal drops the reply",
381 status: http.StatusForbidden,
383 methods: []string{"sendRichMessage"},
384 logText: "message dropped",
388 for _, tc := range testCases {
389 t.Run(tc.name, func(t *testing.T) {
390 telegram := newTelegramFake(t)
392 telegram.fail = func(call TelegramFakeCall) (int, string) {
393 if call.method != "sendRichMessage" || failed && !tc.always {
399 return tc.status, "refused for bot" + telegramTestToken
402 bot, logs := telegramStartTestBot(t, telegram)
403 bot.post(t.Context(), Event{
405 Text: "a **secret** word",
409 for _, call := range telegram.Calls() {
410 methods = append(methods, call.method)
413 require.Equal(t, tc.methods, methods)
414 require.Equal(t, tc.sent, telegram.Sent())
415 require.Equal(t, 1, strings.Count(logs.String(), "\n"))
416 require.Contains(t, logs.String(), tc.logText)
417 require.NotContains(t, logs.String(), telegramTestToken)
418 require.NotContains(t, logs.String(), "secret word")
420 calls := telegram.Calls()
421 if calls[len(calls)-1].method == "sendMessage" {
422 require.NotNil(t, calls[len(calls)-1].params["entities"])
428 func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) {
429 paragraphs := strings.Repeat("A paragraph of the reply.\n\n", telegramRichBytes/len("A paragraph of the reply.\n\n"))
430 table := "| a | b |\n|---|---|\n| 1 | 2 |\n"
431 reply := paragraphs + table
433 telegram := newTelegramFake(t)
434 bot, _ := telegramStartTestBot(t, telegram)
435 bot.post(t.Context(), Event{
440 require.Equal(t, []string{paragraphs, table}, telegram.Sent())
443 func TestReplyTagsGoAsText(t *testing.T) {
444 testCases := []struct {
450 name: "rich message escapes the tag outside code",
451 sent: []string{"a <details> & tag and `<b>`"},
454 name: "entity fallback shows the tag as written",
455 status: http.StatusBadRequest,
456 sent: []string{"a <details> & tag and <b>"},
460 for _, tc := range testCases {
461 t.Run(tc.name, func(t *testing.T) {
462 telegram := newTelegramFake(t)
463 telegram.fail = func(call TelegramFakeCall) (int, string) {
464 if call.method != "sendRichMessage" {
468 return tc.status, "Bad Request"
471 bot, _ := telegramStartTestBot(t, telegram)
472 bot.post(t.Context(), Event{
474 Text: "a <details> & tag and `<b>`",
477 require.Equal(t, tc.sent, telegram.Sent())
482 func TestLongReplySplitCountsTheEscapedTags(t *testing.T) {
483 paragraph := "A <b> tag.\n\n"
484 count := telegramRichBytes / len(paragraph)
485 reply := strings.Repeat(paragraph, count)
486 require.LessOrEqual(t, len(reply), telegramRichBytes)
488 telegram := newTelegramFake(t)
489 bot, _ := telegramStartTestBot(t, telegram)
490 bot.post(t.Context(), Event{
495 sent := telegram.Sent()
496 require.Len(t, sent, 2)
497 require.Equal(t, telegramEscapeTags(reply), strings.Join(sent, ""))
499 for _, markdown := range sent {
500 require.LessOrEqual(t, len(markdown), telegramRichBytes)
504 func TestRefusedRichPartGoesWithTheTextAsWritten(t *testing.T) {
505 paragraph := "A <b> & c.\n\n"
506 reply := strings.Repeat(paragraph, telegramRichBytes/len(paragraph))
508 telegram := newTelegramFake(t)
510 telegram.fail = func(call TelegramFakeCall) (int, string) {
511 if call.method != "sendRichMessage" || refused {
517 return http.StatusBadRequest, "Bad Request"
520 bot, _ := telegramStartTestBot(t, telegram)
521 bot.post(t.Context(), Event{
526 var fallback, rich []string
527 for _, call := range telegram.Calls() {
530 case call.method == "sendMessage":
531 fallback = append(fallback, call.params["text"].(string))
532 case call.method == "sendRichMessage":
533 rich = append(rich, call.params["rich_message"].(map[string]any)["markdown"].(string))
537 require.Greater(t, len(fallback), 1)
538 require.Len(t, rich, 1)
539 require.True(t, strings.HasPrefix(reply, strings.Join(fallback, "")), "fallback text is not the reply as written")
541 for _, text := range fallback {
542 require.LessOrEqual(t, telegramUTF16Units(text), telegramMessageUnits)
545 require.True(t, reply == strings.Join(fallback, "")+telegramUnescapeTags(rich[0]), "reply is not sent once and whole")
548 func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
549 telegram := newTelegramFake(t)
550 telegram.fail = func(call TelegramFakeCall) (int, string) {
551 if call.params["entities"] == nil {
555 return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + telegramTestToken
558 bot, logs := telegramStartTestBot(t, telegram)
559 text, entities := telegramMarkdownEntities("a **secret** word")
560 bot.deliver(t.Context(), TelegramPart{
565 calls := telegram.Calls()
566 require.Len(t, calls, 2)
567 require.NotNil(t, calls[0].params["entities"])
568 require.Nil(t, calls[1].params["entities"])
569 require.Equal(t, []string{"a secret word"}, telegram.Sent())
570 require.Equal(t, 1, strings.Count(logs.String(), "\n"))
571 require.Contains(t, logs.String(), "entities refused")
572 require.NotContains(t, logs.String(), telegramTestToken)
573 require.NotContains(t, logs.String(), "secret word")
576 func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
577 telegram := newTelegramFake(t)
580 bot, logs := telegramStartTestBot(t, telegram)
581 daemonStartTestWithBot(t, newModelFake(t), bot)
583 require.Eventually(t, func() bool {
584 return strings.Count(logs.String(), "no valid answer") >= 2
585 }, 10*time.Second, time.Millisecond)
586 require.NotContains(t, logs.String(), telegramTestToken)
589 func TestSplitMessage(t *testing.T) {
590 testCases := []struct {
597 name: "short text stays whole",
600 parts: []string{"one two"},
603 name: "cut after the last line break",
604 text: "first line\nsecond line",
606 parts: []string{"first line\n", "second line"},
609 name: "cut after the last space without a line break",
610 text: "aaaa bbbb cccc",
612 parts: []string{"aaaa bbbb ", "cccc"},
615 name: "hard cut of one long word",
618 parts: []string{"abcd", "efgh", "ij"},
621 name: "a character outside the BMP counts two units and is not cut",
622 text: "ab\U0001F600cd",
624 parts: []string{"ab", "\U0001F600c", "d"},
628 for _, tc := range testCases {
629 t.Run(tc.name, func(t *testing.T) {
630 require.Equal(t, tc.parts, telegramSplitMessage(tc.text, tc.limit))
634 long := strings.Repeat("word ", 2000)
635 parts := telegramSplitMessage(long, telegramMessageUnits)
636 require.Len(t, parts, 3)
637 require.Equal(t, long, strings.Join(parts, ""))
640 func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
641 parts := telegramSplitEntities("aabbbbcc", []TelegramEntity{
654 require.Equal(t, []TelegramPart{
657 entities: []TelegramEntity{
667 entities: []TelegramEntity{
683 func TestSplitRich(t *testing.T) {
684 testCases := []struct {
691 name: "short reply stays whole",
692 markdown: "| a | b |",
694 parts: []string{"| a | b |"},
697 name: "cut after a blank line keeps a table whole",
698 markdown: "Intro.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
700 parts: []string{"Intro.\n\n", "| a | b |\n|---|---|\n| 1 | 2 |\n"},
703 name: "blank line in a code block is not a cut",
704 markdown: "Text.\n\n```go\nx\n\ny\n```\n\nEnd.\n",
706 parts: []string{"Text.\n\n", "```go\nx\n\ny\n```\n\n", "End.\n"},
709 name: "paragraph with no blank line is cut after a line",
710 markdown: "aaa\nbbb\nccc\n",
712 parts: []string{"aaa\nbbb\n", "ccc\n"},
715 name: "long code block is closed and opened again",
716 markdown: "```sh\none\ntwo\nthree\n```\n",
718 parts: []string{"```sh\none\n```\n", "```sh\ntwo\n```\n", "```sh\nthree\n```\n"},
721 name: "long line is cut at the limit and not inside a character",
722 markdown: "ab\u00e9cd",
724 parts: []string{"ab", "\u00e9c", "d"},
727 name: "long line is not cut inside an entity",
728 markdown: "abc&def",
730 parts: []string{"abc", "&d", "ef"},
734 for _, tc := range testCases {
735 t.Run(tc.name, func(t *testing.T) {
736 require.Equal(t, tc.parts, telegramSplitRich(tc.markdown, tc.limit))
740 long := strings.Repeat("A paragraph of the reply.\n\n", 2000)
741 parts := telegramSplitRich(long, telegramRichBytes)
742 require.Len(t, parts, 2)
743 require.Len(t, parts[0], telegramRichBytes-telegramRichBytes%len("A paragraph of the reply.\n\n"))
744 require.Equal(t, long, strings.Join(parts, ""))
747 func TestLoadBotConfig(t *testing.T) {
748 testCases := []struct {
755 name: "owner file is read",
756 content: `{"token": "` + telegramTestToken + `", "owner": 4242}`,
760 name: "file that others can read is refused",
761 content: `{"token": "` + telegramTestToken + `", "owner": 4242}`,
763 errorText: "mode must be 0600",
766 name: "token with a path character is refused",
767 content: `{"token": "123:abc/../x", "owner": 4242}`,
769 errorText: "form of a bot token",
772 name: "missing owner is refused",
773 content: `{"token": "` + telegramTestToken + `"}`,
775 errorText: "numeric Telegram user ID",
778 name: "unknown field is refused",
779 content: `{"token": "` + telegramTestToken + `", "owner": 4242, "chat": 1}`,
781 errorText: "not valid JSON",
785 for _, tc := range testCases {
786 t.Run(tc.name, func(t *testing.T) {
788 path := filepath.Join(dir, telegramConfigFileName)
789 require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode))
790 require.NoError(t, os.Chmod(path, tc.mode))
792 config, found, err := loadTelegramConfig(dir)
793 if tc.errorText != "" {
794 require.ErrorContains(t, err, tc.errorText)
795 require.NotContains(t, err.Error(), telegramTestToken)
800 require.NoError(t, err)
801 require.True(t, found)
802 require.Equal(t, TelegramConfig{
803 Token: telegramTestToken,
804 Owner: telegramTestOwner,
809 _, found, err := loadTelegramConfig(t.TempDir())
810 require.NoError(t, err)
811 require.False(t, found)
814 // telegramStartTestBot returns a bot of telegramTestOwner on server, with its
815 // offset file in a temporary directory and short waits, and the buffer of its
817 func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *SyncBuffer) {
820 logs := &SyncBuffer{}
821 bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), log.New(logs, "", 0))
822 bot.minWait = time.Millisecond
823 bot.maxWait = 10 * time.Millisecond
828 // telegramWaitForSent waits until the bot sent the texts in order, and returns
830 func telegramWaitForSent(t *testing.T, server *TelegramFake, texts ...string) []string {
833 require.Eventually(t, func() bool {
834 sent := server.Sent()
835 return len(sent) >= len(texts) && slices.Equal(sent[len(sent)-len(texts):], texts)
836 }, 10*time.Second, time.Millisecond, "sent so far: %q", server.Sent())