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 TestBotStartsAtTheStoredOffset(t *testing.T) {
140 server := newModelFake(t)
141 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
142 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
145 telegram := newTelegramFake(t)
146 bot, _ := telegramStartTestBot(t, telegram)
147 require.NoError(t, telegramSaveOffset(bot.offsetPath, 10))
149 telegram.Add(TelegramUpdate{
151 Message: telegramOwnerMessage("handled before the restart"),
154 telegram.Add(TelegramUpdate{
156 Message: telegramOwnerMessage("new after the restart"),
159 socket := daemonStartTestWithBot(t, server, bot)
160 terminal := terminalStartTestClient(t, socket)
161 terminal.WaitFor(t, "[connected to OneChat]")
162 telegramWaitForSent(t, telegram, "ok")
164 require.Equal(t, float64(10), telegram.Calls()[0].params["offset"])
165 require.NotContains(t, terminal.Output(), "handled before the restart")
167 require.Eventually(t, func() bool {
168 offset, err := telegramLoadOffset(bot.offsetPath)
169 return err == nil && offset == 11
170 }, 10*time.Second, time.Millisecond)
173 func TestBotTriesAgainAfterFailures(t *testing.T) {
174 server := newModelFake(t)
175 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
176 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5))
179 telegram := newTelegramFake(t)
180 failures := map[string]int{
182 "sendRichMessage": 1,
185 telegram.fail = func(call TelegramFakeCall) (int, string) {
186 if failures[call.method] == 0 {
190 failures[call.method]--
191 if call.method == "sendRichMessage" {
192 return http.StatusTooManyRequests, "Too Many Requests: retry after 1"
195 return http.StatusBadGateway, "Bad Gateway"
198 bot, logs := telegramStartTestBot(t, telegram)
199 daemonStartTestWithBot(t, server, bot)
201 telegram.Add(TelegramUpdate{
203 Message: telegramOwnerMessage("hello"),
206 telegramWaitForSent(t, telegram, "ok")
207 require.Contains(t, logs.String(), "answered 502")
208 require.Contains(t, logs.String(), "answered 429")
209 require.NotContains(t, logs.String(), telegramTestToken)
212 func TestDeliverTriesAgainAfterATimeout(t *testing.T) {
213 telegram := newTelegramFake(t)
215 telegram.stall = func(call TelegramFakeCall) bool {
216 if call.method != "sendMessage" || stalls == 0 {
225 bot, logs := telegramStartTestBot(t, telegram)
226 bot.api.sendTimeout = 50 * time.Millisecond
227 bot.deliver(t.Context(), TelegramPart{
231 require.Equal(t, []string{"hello"}, telegram.Sent())
232 require.Len(t, telegram.Calls(), 2)
233 require.Contains(t, logs.String(), "no valid answer")
234 require.NotContains(t, logs.String(), telegramTestToken)
237 func TestRepliesGoAsRichMessages(t *testing.T) {
238 reply := "**Done.** The table:\n\n| Name | Size |\n|:-----|-----:|\n| `a.go` | 10 |\n\n- one\n- two\n\n```go\nx := 1\n```"
239 server := newModelFake(t)
240 server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
241 modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(reply)}, 100, 0, 5))
244 telegram := newTelegramFake(t)
245 bot, _ := telegramStartTestBot(t, telegram)
246 socket := daemonStartTestWithBot(t, server, bot)
247 terminal := terminalStartTestClient(t, socket)
248 terminal.WaitFor(t, "[connected to OneChat]")
250 terminal.Type("make **it** so")
251 telegramWaitForSent(t, telegram, "[terminal] make **it** so", reply)
253 telegram.Add(TelegramUpdate{
255 Message: telegramOwnerMessage(""),
258 telegramWaitForSent(t, telegram, telegramTextOnly)
260 var sends []TelegramFakeCall
261 for _, call := range telegram.Calls() {
262 if call.method == "sendMessage" || call.method == "sendRichMessage" {
263 sends = append(sends, call)
267 require.Equal(t, []TelegramFakeCall{
269 method: "sendMessage",
270 params: map[string]any{
271 "chat_id": float64(telegramTestOwner),
272 "text": "[terminal] make **it** so",
277 method: "sendRichMessage",
278 params: map[string]any{
279 "chat_id": float64(telegramTestOwner),
280 "rich_message": map[string]any{
287 method: "sendMessage",
288 params: map[string]any{
289 "chat_id": float64(telegramTestOwner),
290 "text": telegramTextOnly,
297 func TestRichMessageFailures(t *testing.T) {
298 testCases := []struct {
307 name: "bad request sends the reply once with entities",
308 status: http.StatusBadRequest,
310 methods: []string{"sendRichMessage", "sendMessage"},
311 sent: []string{"a secret word"},
312 logText: "rich message refused",
315 name: "unknown method sends the reply once with entities",
316 status: http.StatusNotFound,
318 methods: []string{"sendRichMessage", "sendMessage"},
319 sent: []string{"a secret word"},
320 logText: "rich message refused",
323 name: "rate limit tries the rich message again",
324 status: http.StatusTooManyRequests,
325 methods: []string{"sendRichMessage", "sendRichMessage"},
326 sent: []string{"a **secret** word"},
327 logText: "answered 429",
330 name: "server error tries the rich message again",
331 status: http.StatusInternalServerError,
332 methods: []string{"sendRichMessage", "sendRichMessage"},
333 sent: []string{"a **secret** word"},
334 logText: "answered 500",
337 name: "other refusal drops the reply",
338 status: http.StatusForbidden,
340 methods: []string{"sendRichMessage"},
341 logText: "message dropped",
345 for _, tc := range testCases {
346 t.Run(tc.name, func(t *testing.T) {
347 telegram := newTelegramFake(t)
349 telegram.fail = func(call TelegramFakeCall) (int, string) {
350 if call.method != "sendRichMessage" || failed && !tc.always {
356 return tc.status, "refused for bot" + telegramTestToken
359 bot, logs := telegramStartTestBot(t, telegram)
360 bot.post(t.Context(), Event{
362 Text: "a **secret** word",
366 for _, call := range telegram.Calls() {
367 methods = append(methods, call.method)
370 require.Equal(t, tc.methods, methods)
371 require.Equal(t, tc.sent, telegram.Sent())
372 require.Equal(t, 1, strings.Count(logs.String(), "\n"))
373 require.Contains(t, logs.String(), tc.logText)
374 require.NotContains(t, logs.String(), telegramTestToken)
375 require.NotContains(t, logs.String(), "secret word")
377 calls := telegram.Calls()
378 if calls[len(calls)-1].method == "sendMessage" {
379 require.NotNil(t, calls[len(calls)-1].params["entities"])
385 func TestLongReplyGoesAsRichMessagesSplitBetweenBlocks(t *testing.T) {
386 paragraphs := strings.Repeat("A paragraph of the reply.\n\n", telegramRichBytes/len("A paragraph of the reply.\n\n"))
387 table := "| a | b |\n|---|---|\n| 1 | 2 |\n"
388 reply := paragraphs + table
390 telegram := newTelegramFake(t)
391 bot, _ := telegramStartTestBot(t, telegram)
392 bot.post(t.Context(), Event{
397 require.Equal(t, []string{paragraphs, table}, telegram.Sent())
400 func TestReplyTagsGoAsText(t *testing.T) {
401 testCases := []struct {
407 name: "rich message escapes the tag outside code",
408 sent: []string{"a <details> & tag and `<b>`"},
411 name: "entity fallback shows the tag as written",
412 status: http.StatusBadRequest,
413 sent: []string{"a <details> & tag and <b>"},
417 for _, tc := range testCases {
418 t.Run(tc.name, func(t *testing.T) {
419 telegram := newTelegramFake(t)
420 telegram.fail = func(call TelegramFakeCall) (int, string) {
421 if call.method != "sendRichMessage" {
425 return tc.status, "Bad Request"
428 bot, _ := telegramStartTestBot(t, telegram)
429 bot.post(t.Context(), Event{
431 Text: "a <details> & tag and `<b>`",
434 require.Equal(t, tc.sent, telegram.Sent())
439 func TestLongReplySplitCountsTheEscapedTags(t *testing.T) {
440 paragraph := "A <b> tag.\n\n"
441 count := telegramRichBytes / len(paragraph)
442 reply := strings.Repeat(paragraph, count)
443 require.LessOrEqual(t, len(reply), telegramRichBytes)
445 telegram := newTelegramFake(t)
446 bot, _ := telegramStartTestBot(t, telegram)
447 bot.post(t.Context(), Event{
452 sent := telegram.Sent()
453 require.Len(t, sent, 2)
454 require.Equal(t, telegramEscapeTags(reply), strings.Join(sent, ""))
456 for _, markdown := range sent {
457 require.LessOrEqual(t, len(markdown), telegramRichBytes)
461 func TestRefusedRichPartGoesWithTheTextAsWritten(t *testing.T) {
462 paragraph := "A <b> & c.\n\n"
463 reply := strings.Repeat(paragraph, telegramRichBytes/len(paragraph))
465 telegram := newTelegramFake(t)
467 telegram.fail = func(call TelegramFakeCall) (int, string) {
468 if call.method != "sendRichMessage" || refused {
474 return http.StatusBadRequest, "Bad Request"
477 bot, _ := telegramStartTestBot(t, telegram)
478 bot.post(t.Context(), Event{
483 var fallback, rich []string
484 for _, call := range telegram.Calls() {
487 case call.method == "sendMessage":
488 fallback = append(fallback, call.params["text"].(string))
489 case call.method == "sendRichMessage":
490 rich = append(rich, call.params["rich_message"].(map[string]any)["markdown"].(string))
494 require.Greater(t, len(fallback), 1)
495 require.Len(t, rich, 1)
496 require.True(t, strings.HasPrefix(reply, strings.Join(fallback, "")), "fallback text is not the reply as written")
498 for _, text := range fallback {
499 require.LessOrEqual(t, telegramUTF16Units(text), telegramMessageUnits)
502 require.True(t, reply == strings.Join(fallback, "")+telegramUnescapeTags(rich[0]), "reply is not sent once and whole")
505 func TestRefusedEntitiesGoAgainAsPlainText(t *testing.T) {
506 telegram := newTelegramFake(t)
507 telegram.fail = func(call TelegramFakeCall) (int, string) {
508 if call.params["entities"] == nil {
512 return http.StatusBadRequest, "Bad Request: can't parse entities of bot" + telegramTestToken
515 bot, logs := telegramStartTestBot(t, telegram)
516 text, entities := telegramMarkdownEntities("a **secret** word")
517 bot.deliver(t.Context(), TelegramPart{
522 calls := telegram.Calls()
523 require.Len(t, calls, 2)
524 require.NotNil(t, calls[0].params["entities"])
525 require.Nil(t, calls[1].params["entities"])
526 require.Equal(t, []string{"a secret word"}, telegram.Sent())
527 require.Equal(t, 1, strings.Count(logs.String(), "\n"))
528 require.Contains(t, logs.String(), "entities refused")
529 require.NotContains(t, logs.String(), telegramTestToken)
530 require.NotContains(t, logs.String(), "secret word")
533 func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) {
534 telegram := newTelegramFake(t)
537 bot, logs := telegramStartTestBot(t, telegram)
538 daemonStartTestWithBot(t, newModelFake(t), bot)
540 require.Eventually(t, func() bool {
541 return strings.Count(logs.String(), "no valid answer") >= 2
542 }, 10*time.Second, time.Millisecond)
543 require.NotContains(t, logs.String(), telegramTestToken)
546 func TestSplitMessage(t *testing.T) {
547 testCases := []struct {
554 name: "short text stays whole",
557 parts: []string{"one two"},
560 name: "cut after the last line break",
561 text: "first line\nsecond line",
563 parts: []string{"first line\n", "second line"},
566 name: "cut after the last space without a line break",
567 text: "aaaa bbbb cccc",
569 parts: []string{"aaaa bbbb ", "cccc"},
572 name: "hard cut of one long word",
575 parts: []string{"abcd", "efgh", "ij"},
578 name: "a character outside the BMP counts two units and is not cut",
579 text: "ab\U0001F600cd",
581 parts: []string{"ab", "\U0001F600c", "d"},
585 for _, tc := range testCases {
586 t.Run(tc.name, func(t *testing.T) {
587 require.Equal(t, tc.parts, telegramSplitMessage(tc.text, tc.limit))
591 long := strings.Repeat("word ", 2000)
592 parts := telegramSplitMessage(long, telegramMessageUnits)
593 require.Len(t, parts, 3)
594 require.Equal(t, long, strings.Join(parts, ""))
597 func TestSplitEntitiesCutsAnEntityAcrossASplit(t *testing.T) {
598 parts := telegramSplitEntities("aabbbbcc", []TelegramEntity{
611 require.Equal(t, []TelegramPart{
614 entities: []TelegramEntity{
624 entities: []TelegramEntity{
640 func TestSplitRich(t *testing.T) {
641 testCases := []struct {
648 name: "short reply stays whole",
649 markdown: "| a | b |",
651 parts: []string{"| a | b |"},
654 name: "cut after a blank line keeps a table whole",
655 markdown: "Intro.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
657 parts: []string{"Intro.\n\n", "| a | b |\n|---|---|\n| 1 | 2 |\n"},
660 name: "blank line in a code block is not a cut",
661 markdown: "Text.\n\n```go\nx\n\ny\n```\n\nEnd.\n",
663 parts: []string{"Text.\n\n", "```go\nx\n\ny\n```\n\n", "End.\n"},
666 name: "paragraph with no blank line is cut after a line",
667 markdown: "aaa\nbbb\nccc\n",
669 parts: []string{"aaa\nbbb\n", "ccc\n"},
672 name: "long code block is closed and opened again",
673 markdown: "```sh\none\ntwo\nthree\n```\n",
675 parts: []string{"```sh\none\n```\n", "```sh\ntwo\n```\n", "```sh\nthree\n```\n"},
678 name: "long line is cut at the limit and not inside a character",
679 markdown: "ab\u00e9cd",
681 parts: []string{"ab", "\u00e9c", "d"},
684 name: "long line is not cut inside an entity",
685 markdown: "abc&def",
687 parts: []string{"abc", "&d", "ef"},
691 for _, tc := range testCases {
692 t.Run(tc.name, func(t *testing.T) {
693 require.Equal(t, tc.parts, telegramSplitRich(tc.markdown, tc.limit))
697 long := strings.Repeat("A paragraph of the reply.\n\n", 2000)
698 parts := telegramSplitRich(long, telegramRichBytes)
699 require.Len(t, parts, 2)
700 require.Len(t, parts[0], telegramRichBytes-telegramRichBytes%len("A paragraph of the reply.\n\n"))
701 require.Equal(t, long, strings.Join(parts, ""))
704 func TestLoadBotConfig(t *testing.T) {
705 testCases := []struct {
712 name: "owner file is read",
713 content: `{"token": "` + telegramTestToken + `", "owner": 4242}`,
717 name: "file that others can read is refused",
718 content: `{"token": "` + telegramTestToken + `", "owner": 4242}`,
720 errorText: "mode must be 0600",
723 name: "token with a path character is refused",
724 content: `{"token": "123:abc/../x", "owner": 4242}`,
726 errorText: "form of a bot token",
729 name: "missing owner is refused",
730 content: `{"token": "` + telegramTestToken + `"}`,
732 errorText: "numeric Telegram user ID",
735 name: "unknown field is refused",
736 content: `{"token": "` + telegramTestToken + `", "owner": 4242, "chat": 1}`,
738 errorText: "not valid JSON",
742 for _, tc := range testCases {
743 t.Run(tc.name, func(t *testing.T) {
745 path := filepath.Join(dir, telegramConfigFileName)
746 require.NoError(t, os.WriteFile(path, []byte(tc.content), tc.mode))
747 require.NoError(t, os.Chmod(path, tc.mode))
749 config, found, err := loadTelegramConfig(dir)
750 if tc.errorText != "" {
751 require.ErrorContains(t, err, tc.errorText)
752 require.NotContains(t, err.Error(), telegramTestToken)
757 require.NoError(t, err)
758 require.True(t, found)
759 require.Equal(t, TelegramConfig{
760 Token: telegramTestToken,
761 Owner: telegramTestOwner,
766 _, found, err := loadTelegramConfig(t.TempDir())
767 require.NoError(t, err)
768 require.False(t, found)
771 // telegramStartTestBot returns a bot of telegramTestOwner on server, with its
772 // offset file in a temporary directory and short waits, and the buffer of its
774 func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *SyncBuffer) {
777 logs := &SyncBuffer{}
778 bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), log.New(logs, "", 0))
779 bot.minWait = time.Millisecond
780 bot.maxWait = 10 * time.Millisecond
785 // telegramWaitForSent waits until the bot sent the texts in order, and returns
787 func telegramWaitForSent(t *testing.T, server *TelegramFake, texts ...string) []string {
790 require.Eventually(t, func() bool {
791 sent := server.Sent()
792 return len(sent) >= len(texts) && slices.Equal(sent[len(sent)-len(texts):], texts)
793 }, 10*time.Second, time.Millisecond, "sent so far: %q", server.Sent())