From: Anderson A. Giacomolli Date: Wed, 11 Nov 2026 13:55:28 +0000 (-0300) Subject: Separate multiline Go statements X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/c781f79f3c8cb9a7577ee1574d196988da2719b3?ds=inline Separate multiline Go statements Multiline literals and calls have a blank separator before the next logical block, except for their immediate error check. This includes test case literals before loops. Event fields and empty callbacks use multiline bodies. Whitespace and literal layout do not change behavior. Issue: 3cdc678 --- diff --git a/agent_test.go b/agent_test.go index 8b6775f..5b48c22 100644 --- a/agent_test.go +++ b/agent_test.go @@ -170,6 +170,7 @@ func TestChildConfigNeedsAParentAndAnArea(t *testing.T) { responsibility: "Keep the garden plans.", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() diff --git a/backup.go b/backup.go index 56cf516..9398772 100644 --- a/backup.go +++ b/backup.go @@ -39,6 +39,7 @@ func backup(ctx context.Context, path string, out io.Writer) (err error) { stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stop() err = json.NewEncoder(conn).Encode(SocketRequest{ diff --git a/backup_archive.go b/backup_archive.go index 35a389c..3cd6013 100644 --- a/backup_archive.go +++ b/backup_archive.go @@ -101,6 +101,7 @@ func backupArchive(ctx context.Context, root, path string) (size int64, err erro return nil }) + err = errors.Join(err, archive.Close(), compressed.Close()) if err != nil { return 0, fmt.Errorf("write archive: %w", err) @@ -150,6 +151,7 @@ func backupExtract(ctx context.Context, path, root string) (err error) { ctx: ctx, reader: compressed, }) + seen := make(map[string]bool) var directories []*tar.Header diff --git a/backup_archive_test.go b/backup_archive_test.go index e427333..dff09f8 100644 --- a/backup_archive_test.go +++ b/backup_archive_test.go @@ -111,6 +111,7 @@ func TestRestoreRejectsUnsafeArchiveEntries(t *testing.T) { }, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { archive := backupTestArchive(t, tc.header) diff --git a/backup_test.go b/backup_test.go index 8628ee4..2fb218a 100644 --- a/backup_test.go +++ b/backup_test.go @@ -26,6 +26,7 @@ func TestRunningDaemonBackupRestoresTheChatAndLinks(t *testing.T) { "agent/instructions.md": "Keep the instructions.\n", "agent/telegram-offset.json": "23\n", } + for name, data := range files { path := filepath.Join(dir, name) require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) @@ -236,6 +237,7 @@ func TestUnsafeRestoreInstallsNoArchiveFiles(t *testing.T) { Mode: 0o600, }, ) + dir := t.TempDir() err := backupRestore(t.Context(), archive, dir) require.ErrorContains(t, err, "unsafe archive path") @@ -337,6 +339,7 @@ func TestSessionBackupWaitsForRemovalToFinish(t *testing.T) { require.Never(t, func() bool { return len(backedUp) != 0 }, 50*time.Millisecond, time.Millisecond) + release.Do(func() { close(disconnect.release) }) diff --git a/compactor.go b/compactor.go index e101428..6738955 100644 --- a/compactor.go +++ b/compactor.go @@ -125,6 +125,7 @@ func compactorTask(task Task) (string, error) { %s `, task.Index, nodeBytes, compactorRuler, task.Input[0]) + if len(task.Images) > 0 { text += "\nKeep each [image FILE] name and describe its kind, visible text, amounts, names, and context." } diff --git a/daemon.go b/daemon.go index dd75d31..9f71d80 100644 --- a/daemon.go +++ b/daemon.go @@ -387,6 +387,7 @@ func daemonServe(ctx context.Context, listener net.Listener, bot *TelegramBot, w root: l, messages: messages, } + l.tools.children = runtime var workers sync.WaitGroup diff --git a/daemon_agent_test.go b/daemon_agent_test.go index 3fceb34..5ee342a 100644 --- a/daemon_agent_test.go +++ b/daemon_agent_test.go @@ -26,6 +26,7 @@ func TestDaemonOpensAndRunsTheRootAndTwoChildren(t *testing.T) { t.Cleanup(func() { require.NoError(t, agents.Close()) }) + require.Len(t, agents, 3) require.Equal(t, []string{"Garden", "Library"}, agents.ChildNames()) @@ -253,6 +254,7 @@ func TestInvalidChildRelationshipsStopStartup(t *testing.T) { cause: "must have its own name and ID", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() diff --git a/daemon_children_test.go b/daemon_children_test.go index b9e212f..b377b4d 100644 --- a/daemon_children_test.go +++ b/daemon_children_test.go @@ -49,6 +49,7 @@ func TestDaemonToolsCreateAndTellAChildDuringATurn(t *testing.T) { modelWriteEvents(w, modelCompletedEvent(items, 100, 0, 5)) } + whatsapp, _, _ := whatsappStartTest(t, dir, newTestClock()) listener, err := daemonListenSocket(filepath.Join(dir, socketFileName)) require.NoError(t, err) @@ -111,6 +112,7 @@ func TestChildCreationRefusesUsedNames(t *testing.T) { test.root.runs.subagents.draw = func() string { return "BlueFox" } + subagent, err := test.root.runs.subagents.Create(t.Context()) require.NoError(t, err) require.Equal(t, "BlueFox", subagent.name) @@ -145,6 +147,7 @@ func TestChildCreationAcceptsAreaNames(t *testing.T) { child: "A" + strings.Repeat("b", 31), }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { test := newDaemonChildrenTest(t) @@ -209,6 +212,7 @@ func TestChildCreationRefusesInvalidAreaNames(t *testing.T) { child: "Hous\u00e9", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { test := newDaemonChildrenTest(t) @@ -320,6 +324,7 @@ func TestChildNamesStayOutOfSubagentDraws(t *testing.T) { return name } + subagent, err := test.root.runs.subagents.Create(t.Context()) require.NoError(t, err) require.Equal(t, "BlueFox", subagent.name) @@ -364,6 +369,7 @@ func TestZoomReadsTheChildViewAndKeepsSubagentLogs(t *testing.T) { test.root.runs.subagents.draw = func() string { return "BlueFox" } + subagent, err := test.root.runs.subagents.Create(t.Context()) require.NoError(t, err) @@ -439,6 +445,7 @@ func newDaemonChildrenTest(t *testing.T) *DaemonChildrenTest { test := &DaemonChildrenTest{ server: newModelFake(t), } + agents, err := openDaemonAgents(t.Context(), t.TempDir(), test.server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog()) require.NoError(t, err) @@ -457,6 +464,7 @@ func newDaemonChildrenTest(t *testing.T) *DaemonChildrenTest { root: test.root, messages: messages, } + test.root.tools.children = test.runtime t.Cleanup(func() { cancel() diff --git a/daemon_message.go b/daemon_message.go index b70947d..eade727 100644 --- a/daemon_message.go +++ b/daemon_message.go @@ -65,6 +65,7 @@ func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) { ordered: append([]*TurnLoop(nil), loops...), loops: make(map[string]*TurnLoop), } + for _, loop := range loops { m.loops[loop.agent.config.Name] = loop loop.messages = m @@ -173,6 +174,7 @@ func (m *DaemonMessages) deliver(message *DaemonMessage) error { Source: source, Status: status, }) + loop.Wake(logged) if message.WhatsAppBatch > 0 { @@ -370,6 +372,7 @@ func (m *DaemonMessages) PruneWhatsApp(batches []WhatsAppBatch) error { m.state.WhatsAppBatches = slices.DeleteFunc(m.state.WhatsAppBatches, func(number int) bool { return !held[number] }) + if len(m.state.WhatsAppBatches) == before { return nil } diff --git a/daemon_message_helper_test.go b/daemon_message_helper_test.go index 2a627e2..4ac80d3 100644 --- a/daemon_message_helper_test.go +++ b/daemon_message_helper_test.go @@ -36,6 +36,7 @@ func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMe test := &DaemonMessageTest{ logs: &SyncBuffer{}, } + test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs)) require.NoError(t, err) t.Cleanup(func() { diff --git a/daemon_message_test.go b/daemon_message_test.go index 432a640..27138d3 100644 --- a/daemon_message_test.go +++ b/daemon_message_test.go @@ -24,6 +24,7 @@ func TestParentMessageWakesChildAndFinalReplyWakesParent(t *testing.T) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5)) } + test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 0, 1, 2) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the gate size.")) @@ -68,6 +69,7 @@ func TestChildReportSendsATelegramCompletionBeforeTheParentReply(t *testing.T) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5)) } + test := newDaemonMessageTest(t, t.TempDir(), server) telegram := newTelegramFake(t) bot := newTelegramBot(newTelegramAPI(telegram.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), discardLog()) @@ -97,6 +99,7 @@ func TestChildReportSendsATelegramCompletionBeforeTheParentReply(t *testing.T) { require.Eventually(t, func() bool { return len(telegram.Sent()) >= 2 }, 10*time.Second, time.Millisecond) + require.Equal(t, []string{"[Garden] finished", "I will ask the user."}, telegram.Sent()) require.Equal(t, subagentFinished, test.events[0].LastEvent("work").Status) require.Equal(t, "[Garden] Approve the gate size.", test.events[0].Last("work")) @@ -130,12 +133,14 @@ func TestEmptyChildReplySendsNothingAndWakesNobody(t *testing.T) { text: " \n\t ", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(tc.text)}, 100, 0, 5)) } + test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 0, 1, 2) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Keep the plan.")) @@ -162,6 +167,7 @@ func TestBusyChildQueuesTheNextParentMessage(t *testing.T) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } + test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 0, 1) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the first plan.")) @@ -194,6 +200,7 @@ func TestBusyParentQueuesItsChildReport(t *testing.T) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(text)}, 100, 0, 5)) } + test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 0, 1) @@ -232,6 +239,7 @@ func TestChildReportsOnlyTheFinalReplyAndTakesQueuedToolInput(t *testing.T) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan needs approval.")}, 100, 0, 5)) } + test := newDaemonMessageTest(t, t.TempDir(), server) test.Start(t, 1) require.NoError(t, test.messages.Tell("OneChat", "Garden", "Check the plan.")) @@ -259,6 +267,7 @@ func TestQueuedParentMessageSurvivesDaemonRestart(t *testing.T) { server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } + first := newDaemonMessageTest(t, dir, server) require.NoError(t, first.messages.Tell("OneChat", "Garden", "Remember the garden plan.")) require.Equal(t, 1, first.agents[1].memory.Len()) @@ -319,6 +328,7 @@ func TestInterruptedChildDeliveryResumesAfterDaemonRestart(t *testing.T) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } + first := newDaemonMessageTest(t, dir, server) first.Start(t, 1) require.NoError(t, first.messages.Tell("OneChat", "Garden", "Finish the garden plan.")) @@ -348,6 +358,7 @@ func TestInterruptedParentReportResumesAfterDaemonRestart(t *testing.T) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("The plan is ready.")}, 100, 0, 5)) } + first := newDaemonMessageTest(t, dir, server) first.Start(t, 0, 1) require.NoError(t, first.messages.Tell("OneChat", "Garden", "Review the plan.")) @@ -419,6 +430,7 @@ func TestAgentMessageSnapshotBlocksDelivery(t *testing.T) { require.Never(t, func() bool { return len(sent) > 0 }, 50*time.Millisecond, time.Millisecond) + once.Do(func() { close(release) }) @@ -463,6 +475,7 @@ func TestParentCanMessageOnlyItsOwnChildren(t *testing.T) { target: "Other", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.ErrorContains(t, test.messages.Tell(tc.source, tc.target, "Do not deliver."), "not a child") @@ -497,6 +510,7 @@ func TestInvalidAgentMessageQueueStopsStartup(t *testing.T) { file: `{"next":0,"pending":[{"id":1,"source":"OneChat","target":"Garden","kind":"user"}]}`, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() @@ -537,6 +551,7 @@ func TestChildScheduleWakesOnlyItsOwner(t *testing.T) { server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } + test := newDaemonMessageTest(t, t.TempDir(), server) child := test.agents[1] at := child.clock().Add(time.Minute).Format(scheduleAtLayout) @@ -580,12 +595,14 @@ func TestRoutedWhatsAppBatchesWakeOnlyTheirChild(t *testing.T) { }, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { server := newModelFake(t) server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } + dir := t.TempDir() test := newDaemonMessageTest(t, dir, server) inbox := whatsappRouteTestInbox(t, dir) @@ -621,6 +638,7 @@ func TestWhatsAppUnrouteRestoresRootDelivery(t *testing.T) { ID: whatsappTestAna.String(), Name: "Ana", } + source := "whatsapp:" + chat.ID require.NoError(t, test.messages.routes.Set(source, "Garden")) whatsappRouteTestAdd(t, inbox, chat, "Keep the gate plan.") @@ -672,6 +690,7 @@ func TestRoutedWhatsAppRestartDoesNotDeliverTheBatchTwice(t *testing.T) { finish: true, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() @@ -679,12 +698,14 @@ func TestRoutedWhatsAppRestartDoesNotDeliverTheBatchTwice(t *testing.T) { server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } + first := newDaemonMessageTest(t, dir, server) inbox := whatsappRouteTestInbox(t, dir) chat := WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", } + source := "whatsapp:" + chat.ID require.NoError(t, first.messages.routes.Set(source, "Garden")) whatsappRouteTestAdd(t, inbox, chat, "Remember the gate plan.") @@ -747,6 +768,7 @@ func TestQueuedWhatsAppBatchRecoversBeforeItsLogAppend(t *testing.T) { id: new(0), }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() @@ -768,6 +790,7 @@ func TestQueuedWhatsAppBatchRecoversBeforeItsLogAppend(t *testing.T) { WhatsAppBatch: 1, }}, } + data, err := json.Marshal(file) require.NoError(t, err) require.NoError(t, os.WriteFile(path, data, 0o600)) @@ -824,6 +847,7 @@ func TestRoutedWhatsAppBatchesCanCloseOutOfNumberOrder(t *testing.T) { ID: whatsappTestAna.String(), Name: "Ana", } + second := WhatsAppChat{ ID: whatsappTestFamily.String(), Name: "Garden plans", @@ -867,10 +891,12 @@ func TestWhatsAppCatchupSeparatesChildAndRootBatches(t *testing.T) { Name: "Garden plans", Group: true, } + library := WhatsAppChat{ ID: whatsappTestBruno.String(), Name: "Bruno", } + root := WhatsAppChat{ ID: whatsappTestAna.String(), Name: "Ana", diff --git a/image_model.go b/image_model.go index 6965142..71f51aa 100644 --- a/image_model.go +++ b/image_model.go @@ -29,6 +29,7 @@ func imageParts(store *ImageStore, text string, names []string) ([]ModelInputPar Type: "input_text", Text: text, }} + for _, name := range names { if store == nil { return nil, errors.New("image store is not available") diff --git a/image_model_test.go b/image_model_test.go index 53b0f31..04f2b4b 100644 --- a/image_model_test.go +++ b/image_model_test.go @@ -105,6 +105,7 @@ func TestLeafCompactionDescribesImagesOnceAndMergesOnlyText(t *testing.T) { Input: []string{"user: " + imageText([]string{name}, "Read the receipt.")}, Images: []string{name}, } + text, err := compactor.Summarize(t.Context(), task) require.NoError(t, err) require.Equal(t, "receipt, Ada, $12, red square", text) diff --git a/image_show_test.go b/image_show_test.go index 8df642a..9317cbb 100644 --- a/image_show_test.go +++ b/image_show_test.go @@ -22,6 +22,7 @@ func TestImageShowUsesStoredImageForTelegramAndTerminal(t *testing.T) { store: store, bot: bot, }} + args, err := json.Marshal(ToolImageShowArguments{ Image: name, Caption: "A red point.", @@ -79,6 +80,7 @@ func TestImageShowFallsBackOnlyWhenTelegramRefusesPhoto(t *testing.T) { failure: true, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { telegram := newTelegramFake(t) @@ -89,6 +91,7 @@ func TestImageShowFallsBackOnlyWhenTelegramRefusesPhoto(t *testing.T) { return 0, "" } + store := &ImageStore{dir: t.TempDir()} picture := imageTestPNG(t, 3, 3) name, err := store.Save(picture) @@ -99,6 +102,7 @@ func TestImageShowFallsBackOnlyWhenTelegramRefusesPhoto(t *testing.T) { store: store, bot: bot, } + _, err = show.Show(t.Context(), name, "Caption") if tc.failure { require.Error(t, err) @@ -194,6 +198,7 @@ func TestAgentShowsTelegramReceivedImageToItsOwner(t *testing.T) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Shown.")}, 100, 0, 5)) } + socket := daemonStartTestWithBot(t, server, bot) terminal := terminalStartTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") @@ -205,10 +210,12 @@ func TestAgentShowsTelegramReceivedImageToItsOwner(t *testing.T) { Width: 6, Height: 4, }} + telegram.Add(TelegramUpdate{ UpdateID: 1, Message: message, }) + terminal.WaitFor(t, "Shown.\n[done]") require.Contains(t, terminal.Output(), "[result] [image "+name+"]") require.Contains(t, terminal.Output(), filepath.Join(imageDirName, name)) diff --git a/image_test.go b/image_test.go index 24007d7..d92410e 100644 --- a/image_test.go +++ b/image_test.go @@ -20,6 +20,7 @@ func TestImagesArePrivateSharedAndWrittenOnce(t *testing.T) { dir: filepath.Join(root.dir, "children", "House"), config: AgentConfig{Parent: root.config.Name}, } + require.Equal(t, root.Images().dir, child.Images().dir) data := imageTestPNG(t, 3, 2) diff --git a/journal.go b/journal.go index bb1897c..3fb8f64 100644 --- a/journal.go +++ b/journal.go @@ -71,6 +71,7 @@ func (j *Journal) Write(day string, line []byte) (Position, error) { offset: j.size, length: uint32(len(line)), } + j.size += uint64(len(line)) return place, nil diff --git a/memory.go b/memory.go index df99a95..5ea9007 100644 --- a/memory.go +++ b/memory.go @@ -659,6 +659,7 @@ func (m *Memory) run(key NodeKey, task Task) { Text: text, Size: len(text), }) + m.syncer.Written() } diff --git a/memory_test.go b/memory_test.go index 6713f6d..f317b0a 100644 --- a/memory_test.go +++ b/memory_test.go @@ -143,6 +143,7 @@ func TestMemoryRunsAtMostEightBuilds(t *testing.T) { slices.SortFunc(running, func(a, b NodeKey) int { return a.index - b.index }) + require.Equal(t, []NodeKey{{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, {0, 6}, {0, 7}}, running) require.Eventually(t, func() bool { @@ -497,6 +498,7 @@ func TestQueuedAppendRefusesAnInvalidReceipt(t *testing.T) { cause: "read queued message", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { _, err := memory.AppendOnce("user", "Other text.", &tc.id, func(int) error { diff --git a/model_test.go b/model_test.go index 90d0e93..3ea82d4 100644 --- a/model_test.go +++ b/model_test.go @@ -50,6 +50,7 @@ func TestClientSendsTheFixedRequestFields(t *testing.T) { map[string]any{"type": "input_text", "text": "hello"}, }, }}, body["input"]) + require.Len(t, body["tools"], 1) } diff --git a/prompt.go b/prompt.go index 29e58ee..0571ecf 100644 --- a/prompt.go +++ b/prompt.go @@ -223,6 +223,7 @@ message outside your area. Your final reply is your report to the parent. End your turn with an empty reply when nothing needs the parent or the user. Send nothing to WhatsApp. Draft replies in your report instead, so the parent can ask the user before sending.`, 1) + text = strings.Replace(text, promptChildrenText, "", 1) return promptWithInstructions(text, "Do the tasks of your area yourself, with your tools.", promptInstructionsClause, instructions) diff --git a/prompt_test.go b/prompt_test.go index 888057f..158784b 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -65,6 +65,7 @@ func TestPromptEndsWithTheInstructions(t *testing.T) { require.Contains(t, text, `Do the user's tasks yourself, with your tools, following the user's instructions at the end of this prompt: who they are, how their files are organized and how they want work done.`) + require.True(t, strings.HasSuffix(text, "\n\n# The user's instructions\n\nI am Lena. Keep replies short.")) } diff --git a/routes.go b/routes.go index 02e28e2..6d72898 100644 --- a/routes.go +++ b/routes.go @@ -30,6 +30,7 @@ func openRoutes(path string, logger *slog.Logger) (*Routes, error) { logger: logger, targets: make(map[string]string), } + data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return r, nil diff --git a/routes_test.go b/routes_test.go index dda77ea..2aadc7e 100644 --- a/routes_test.go +++ b/routes_test.go @@ -88,6 +88,7 @@ func TestRoutesRefuseUnknownSourcesAndNonchildren(t *testing.T) { cause: "must be a child", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { arguments := `{"source":"` + tc.source + `","agent":"` + tc.agent + `"}` @@ -122,6 +123,7 @@ func TestWhatsAppStatusListsRoutes(t *testing.T) { whatsapp.Command("status", func(text string) { answer = text }) + require.Contains(t, answer, "WhatsApp is not linked.") require.Contains(t, answer, "WhatsApp routes:\nwhatsapp:"+whatsappTestFamily.String()+" -> Garden") } @@ -144,6 +146,7 @@ func TestInvalidRouteFileStopsStartup(t *testing.T) { text: `{"whatsapp:example@lid":""}`, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "routes.json") diff --git a/schedule_test.go b/schedule_test.go index cb839d5..03d33c7 100644 --- a/schedule_test.go +++ b/schedule_test.go @@ -287,6 +287,7 @@ func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) { "[Schedule 1] Sunday 2026-11-08 09:00 -03:00, once\nRemind the user to call.\n" + scheduleLateLine + "\n" + scheduleEmptyReply, "[Schedule 2] Sunday 2026-11-08 10:00 -03:00, repeats 0 * * * *\nTell the user the time.\n" + scheduleLateLine + "\n" + scheduleEmptyReply, }, texts[len(texts)-2:]) + require.Equal(t, "2 | Sunday 2026-11-08 21:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List()) require.Equal(t, "<6>schedule ran id=1 late=11h30m0s\n<6>schedule ran id=2 late=10h30m0s\n", logs.String()) } diff --git a/subagent.go b/subagent.go index 035f57d..437aeb5 100644 --- a/subagent.go +++ b/subagent.go @@ -174,6 +174,7 @@ func (s *Subagents) Get(ctx context.Context, name string) (*Subagent, error) { log: log, syncer: syncer, } + s.open[name] = subagent return subagent, nil diff --git a/subagent_run_test.go b/subagent_run_test.go index 71c96ea..efd387c 100644 --- a/subagent_run_test.go +++ b/subagent_run_test.go @@ -64,6 +64,7 @@ func TestSpawnRunsASubagentInTheBackground(t *testing.T) { "work: " + report, "reply: Noted.", }, test.Log(t)) + require.Equal(t, Event{ Kind: "work", ID: 4, @@ -71,6 +72,7 @@ func TestSpawnRunsASubagentInTheBackground(t *testing.T) { Source: name, Status: subagentFinished, }, test.events.LastEvent("work")) + require.Equal(t, "<6>subagent started name="+name+"\n<6>subagent done name="+name+" after=0s\n", test.logs.String()) var first, second *ModelFakeCall @@ -198,6 +200,7 @@ func TestFullContextGivesAFailedReport(t *testing.T) { "work: [" + name + "]\nfailed: create model response: model call: HTTP 400: " + subagentTestFullContext + "\n\nFull chat: zoom(\"" + name + "\")", "reply: It failed.", }, test.Log(t)) + require.Equal(t, subagentFailed, test.events.LastEvent("work").Status) require.Equal(t, "<6>subagent started name="+name+"\n<3>subagent failed name="+name+" after=0s: create model response: model call: HTTP 400: "+subagentTestFullContext+"\n", test.logs.String()) } @@ -385,6 +388,7 @@ func TestStopEndsASubagentAndItsCommand(t *testing.T) { "note: Stopped by the user: " + name + ".", "work: " + subagentReport(name, []string{"stopped"}), }, test.Log(t)) + require.Equal(t, subagentStopped, test.events.LastEvent("work").Status) require.Equal(t, "<6>subagent started name="+name+"\n<6>subagent stopped name="+name+" after=0s\n", test.logs.String()) @@ -518,6 +522,7 @@ func TestRestartReportsASubagentThatCannotGoOn(t *testing.T) { "work: " + subagentReport("BlueFox", []string{"failed: no agent BlueFox"}), "reply: a line", }, subagentTestChat(t, memory)) + require.Equal(t, subagentFailed, events.LastEvent("work").Status) require.Equal(t, "<3>subagent lost at restart name=BlueFox: no agent BlueFox\n", logs.String()) diff --git a/syncer.go b/syncer.go index 0784393..196eb97 100644 --- a/syncer.go +++ b/syncer.go @@ -43,6 +43,7 @@ func openSyncer(dir string) (*Syncer, error) { s := newSyncer(func() error { return unix.Syncfs(int(file.Fd())) }) + s.file = file return s, nil diff --git a/telegram_api_test.go b/telegram_api_test.go index ca11274..03d6c63 100644 --- a/telegram_api_test.go +++ b/telegram_api_test.go @@ -234,6 +234,7 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { tc.reply(w) })) + t.Cleanup(server.Close) err := newTelegramAPI(server.URL, telegramTestToken).SendMessage(t.Context(), telegramTestOwner, "hi", nil) diff --git a/telegram_fake_helper_test.go b/telegram_fake_helper_test.go index 6082d7d..e860d65 100644 --- a/telegram_fake_helper_test.go +++ b/telegram_fake_helper_test.go @@ -79,6 +79,7 @@ func newTelegramLocalFake(t *testing.T) *TelegramFake { added: make(chan struct{}, 1), stopped: make(chan struct{}), } + t.Cleanup(func() { close(f.stopped) }) diff --git a/telegram_image_test.go b/telegram_image_test.go index 7f7b7c0..94a014b 100644 --- a/telegram_image_test.go +++ b/telegram_image_test.go @@ -50,6 +50,7 @@ func TestTelegramSelectsLargestPhotoAtMost1568Pixels(t *testing.T) { UpdateID: 1, Message: message, })) + test.events.WaitFor(t, "idle", 1) files, err := os.ReadDir(test.agent.Images().dir) @@ -163,6 +164,7 @@ func TestTelegramImageDocumentsUseContentFormatAndRejectOtherMedia(t *testing.T) failure: "exceeds 20 MB", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { text, err := telegramImageText(t.Context(), api, store, &TelegramMessage{Document: &tc.document}) @@ -193,6 +195,7 @@ func TestTelegramRetriesImageDownloadBeforeAdvancingOffset(t *testing.T) { return 0, "" } + bot, logs := telegramStartTestBot(t, telegram) server := newModelFake(t) socket := daemonStartTestWithBot(t, server, bot) @@ -205,15 +208,18 @@ func TestTelegramRetriesImageDownloadBeforeAdvancingOffset(t *testing.T) { Width: 2, Height: 2, }} + telegram.Add(TelegramUpdate{ UpdateID: 7, Message: message, }) + terminal.WaitFor(t, "[done]") require.Eventually(t, func() bool { offset, err := telegramLoadOffset(bot.offsetPath) return err == nil && offset == 8 }, 10*time.Second, time.Millisecond) + require.Contains(t, logs.String(), "receive failed") require.Contains(t, logs.String(), "receive back") require.NotContains(t, logs.String(), telegramTestToken) @@ -245,6 +251,7 @@ func TestTelegramStorageFailureDoesNotAcceptTheImage(t *testing.T) { Width: 2, Height: 2, }} + err := bot.handle(t.Context(), test.loop, nil, TelegramUpdate{ UpdateID: 1, Message: message, diff --git a/telegram_test.go b/telegram_test.go index dee692d..8d033ab 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -192,11 +192,13 @@ func TestBotSkipsTheWorkOfWhatsAppAndTheSchedules(t *testing.T) { 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) } @@ -646,6 +648,7 @@ func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) { require.Eventually(t, func() bool { return strings.Contains(logs.String(), "<4>receive failed retry=1s: no answer: ") }, 10*time.Second, time.Millisecond) + require.NotContains(t, logs.String(), telegramTestToken) } @@ -694,6 +697,7 @@ func TestBotLogsAnOutageOnlyAtItsStartAndEnd(t *testing.T) { "<4>receive failed retry=1s: 502 Bad Gateway", "<6>receive back after=31s", }, lines) + require.NotContains(t, logs.String(), "secret word") require.NotContains(t, logs.String(), strconv.FormatInt(telegramTestOwner, 10)) cancel() diff --git a/telegram_voice_test.go b/telegram_voice_test.go index ee5ee3f..cd2211c 100644 --- a/telegram_voice_test.go +++ b/telegram_voice_test.go @@ -64,6 +64,7 @@ func TestTelegramVoiceFailuresKeepFollowingTextMoving(t *testing.T) { marker: "transcriber or model is not available", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { test := newTestTurnLoop(t, newModelFake(t)) diff --git a/terminal_image_test.go b/terminal_image_test.go index 46a7cfd..c62d7da 100644 --- a/terminal_image_test.go +++ b/terminal_image_test.go @@ -87,6 +87,7 @@ func TestTerminalImagePathsAndCaptionsHaveNoShellExpansion(t *testing.T) { command: false, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { request, command := terminalImageRequest(tc.line) @@ -138,6 +139,7 @@ func TestTerminalRefusesLargeAndUnsupportedImagesWithoutAModelCall(t *testing.T) require.NoError(t, os.WriteFile(path, []byte("not an image"), 0o600)) require.ErrorContains(t, daemonRunRequest(test.loop, nil, request, func(string) { }), "JPEG, PNG, or WebP") + require.Equal(t, 0, test.memory.Len()) require.Equal(t, 0, server.Calls()) diff --git a/terminal_markdown_test.go b/terminal_markdown_test.go index adf0fa4..a1676f3 100644 --- a/terminal_markdown_test.go +++ b/terminal_markdown_test.go @@ -88,6 +88,7 @@ func TestTerminalMarkdownBlocksAndMarks(t *testing.T) { want: []string{"safe[31m end"}, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, terminalMarkdown(tc.text, tc.columns)) @@ -145,6 +146,7 @@ func TestTerminalMarkdownNarrowLinesKeepQuoteAndListMarks(t *testing.T) { want: []string{"| a", "| b"}, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, terminalMarkdown(tc.text, tc.columns)) diff --git a/terminal_test.go b/terminal_test.go index 7b68edb..c93c46f 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -96,6 +96,7 @@ func TestTerminalEventsKeepMarkdownInNonterminalOutput(t *testing.T) { Kind: "work", Text: "[BlueBird]\n" + text, }) + require.Equal(t, text+"\n[BlueBird]\n"+text+"\n", out.String()) } @@ -112,6 +113,7 @@ func TestTerminalFileOutputKeepsMarkdown(t *testing.T) { Kind: "reply", Text: text, }) + require.Zero(t, terminalColumns(out)) contents, err := os.ReadFile(out.Name()) @@ -173,6 +175,7 @@ func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) { Event{Kind: "busy"}, Event{Kind: "idle"}, ) + require.Equal(t, "[working]\n\x1b[1mfirst next\x1b[0m\n\x1b[1mlast\x1b[0m\n[BlueBird]\n\x1b[1mReport\x1b[0m\n"+ "[tool] **raw tool**\n[result] **raw result**\n[error] **raw error**\n**raw notice**\n[telegram] **raw input**\n"+ "[connected to **raw agent**]\n[BlueBird] started\n[working]\n[done]\n> ", out.String()) @@ -193,6 +196,7 @@ func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) { Kind: "reply", Text: strings.Repeat("a", 81), }) + require.Equal(t, strings.Repeat("a", 80)+"\na\n> ", out.String()) require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 40})) require.Equal(t, 40, terminalColumns(&out)) @@ -241,6 +245,7 @@ func TestTerminalPromptsAfterIdleEventsAndClearsBeforeShownEvents(t *testing.T) Text: "Idle error", }, ) + require.Equal(t, "[connected to Example]\n> "+ "\r\x1b[2KIdle notice\n> "+ "\r\x1b[2K\x1b[1mIdle report\x1b[0m\n> "+ @@ -275,6 +280,7 @@ func TestTerminalNonterminalEventsHaveNoPromptOrClearCommands(t *testing.T) { Text: "Idle again", }, ) + require.Equal(t, "[connected to Example]\nIdle notice\n[working]\nBusy notice\n**Answer**\n[done]\nIdle again\n", out.String()) } diff --git a/tools.go b/tools.go index 11e208d..94885ff 100644 --- a/tools.go +++ b/tools.go @@ -494,9 +494,11 @@ func (b *Toolbox) shell(ctx context.Context, command string) string { cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, } + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = toolShellOutputTimeout output := &ToolHeadTail{} diff --git a/tools_test.go b/tools_test.go index e1b19bb..23507f6 100644 --- a/tools_test.go +++ b/tools_test.go @@ -29,6 +29,7 @@ func TestToolRunsAfterItsCallIsDurable(t *testing.T) { "flush, marker absent", "echo: done\n", }, log.Events()) + require.FileExists(t, filepath.Join(dir, "ran")) } diff --git a/tree_test.go b/tree_test.go index d927a9e..2f23d4d 100644 --- a/tree_test.go +++ b/tree_test.go @@ -72,6 +72,7 @@ func TestTreeReopenLoadsTheSameNodes(t *testing.T) { treeTestNode(0, 0, "user: a"), treeTestNode(1, 0, "user: a\nreply: b"), } + for _, node := range nodes { require.NoError(t, tree.Add(node)) } @@ -107,6 +108,7 @@ func TestTreeBuildsAMissingIndexAgain(t *testing.T) { treeTestNode(0, 0, "user: a"), treeTestNode(1, 0, "user: a\nreply: b"), } + for _, node := range nodes { require.NoError(t, tree.Add(node)) } diff --git a/turn_test.go b/turn_test.go index 733c9a9..5341de5 100644 --- a/turn_test.go +++ b/turn_test.go @@ -57,6 +57,7 @@ func TestTurnRunsToolsUntilTheFinalAnswer(t *testing.T) { "echo: hi\n", "reply: It said hi.", }, test.Log(t)) + require.Equal(t, []string{"user", "busy", "tool", "echo", "reply", "idle"}, test.events.Kinds()) var turns []string diff --git a/voice_transcriber.go b/voice_transcriber.go index 976d66b..732b50a 100644 --- a/voice_transcriber.go +++ b/voice_transcriber.go @@ -80,6 +80,7 @@ func (v *VoiceTranscriber) transcribe(ctx context.Context, path string) (text st output := filepath.Join(dir, "text") command := exec.CommandContext(ctx, program, "--model", model, "--file", path, "--language", "auto", "--max-context", "0", "--output-txt", "--output-file", output) + command.Stdout = io.Discard command.Stderr = io.Discard command.WaitDelay = voiceProcessExitTimeout diff --git a/voice_transcriber_test.go b/voice_transcriber_test.go index c9e24bc..c04d091 100644 --- a/voice_transcriber_test.go +++ b/voice_transcriber_test.go @@ -29,6 +29,7 @@ printf ' A made-up voice transcript.\n' > "$output.txt" printf 'private speech in process output' printf 'private speech in process errors' >&2 `) + data := []byte("made-up audio") text := transcriber.Text(t.Context(), store, data) require.Contains(t, text, "[voice ") @@ -70,6 +71,7 @@ func TestVoiceFailuresKeepStoredAudioAndSafeMarkers(t *testing.T) { script: "while [ \"$1\" != --output-file ]; do shift; done; shift; touch \"$1.txt\"", }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { store, transcriber := voiceTestInput(t, tc.script) @@ -208,6 +210,7 @@ func TestVoiceLookupHonorsExplicitThenNixThenPath(t *testing.T) { program: valid.Transcriber, }, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { program, model, err := voiceLookup(tc.config, tc.program, tc.model) diff --git a/whatsapp.go b/whatsapp.go index ee785a3..738ef8d 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -279,7 +279,8 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err var pairing sync.WaitGroup - cancel := func() {} + cancel := func() { + } defer func() { cancel() pairing.Wait() @@ -316,7 +317,8 @@ func (w *WhatsApp) Run(ctx context.Context, l *TurnLoop, notify func(Event)) err cancel() pairing.Wait() - cancel = func() {} + cancel = func() { + } switch { case request.unlink: diff --git a/whatsapp_import_test.go b/whatsapp_import_test.go index 4bd0a1f..553a2cb 100644 --- a/whatsapp_import_test.go +++ b/whatsapp_import_test.go @@ -486,6 +486,7 @@ func TestWhatsAppImportChildPlanReadsRootCutoffButNotRootImportedDays(t *testing Date: "2025-01-03T10:20:00Z", }, }) + rebuildTestLog(t, childDir, []DatedMessage{ { Kind: "work", @@ -529,6 +530,7 @@ func TestWhatsAppImportChildRebuildUsesOwnPromptAndCache(t *testing.T) { Text: "Keep the root history.", Date: "2025-02-01T00:00:00Z", }}) + rebuildTestLog(t, childDir, []DatedMessage{{ Kind: "user", Text: "Keep the child history.", diff --git a/whatsapp_send_test.go b/whatsapp_send_test.go index 85b7d15..a2e9a92 100644 --- a/whatsapp_send_test.go +++ b/whatsapp_send_test.go @@ -335,6 +335,7 @@ func TestWhatsAppImageFailureKeepsNoSecretsAndDoesNotRetry(t *testing.T) { }, {name: "send failure stores a safe cause"}, } + for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { whatsapp, fake := whatsappStartTestLinked(t) @@ -432,6 +433,7 @@ func TestTelegramReceivedPhotoCanBeShownAndSentToWhatsApp(t *testing.T) { bot: bot, }, } + require.NotContains(t, box.run(t.Context(), "image_show", `{"image":"`+names[0]+`","caption":"The plan."}`), "error:") require.Contains(t, box.run(t.Context(), "whatsapp_send", `{"id":"forward","chat":"15550002222@s.whatsapp.net","text":"The plan.","image":"`+names[0]+`"}`), "Sent to") require.Equal(t, [][]byte{picture}, fake.Uploads()) diff --git a/whatsapp_state.go b/whatsapp_state.go index 825fa18..269f46d 100644 --- a/whatsapp_state.go +++ b/whatsapp_state.go @@ -139,6 +139,7 @@ func (s *WhatsAppState) Add(chat WhatsAppChat, message WhatsAppMessage, arrival Chat: chat, First: arrival, }) + index = len(s.file.Batches) - 1 } diff --git a/whatsapp_test.go b/whatsapp_test.go index 9ed4105..05ea123 100644 --- a/whatsapp_test.go +++ b/whatsapp_test.go @@ -124,6 +124,7 @@ func TestWhatsAppLinkedPairRequestKeepsFirstConnectionRetries(t *testing.T) { test.whatsapp.Command("pair 15550001111", func(text string) { answers <- text }) + require.Equal(t, "WhatsApp is linked already. Run /whatsapp unlink first.", <-answers) synctest.Wait() test.fake.mutex.Lock() @@ -194,6 +195,7 @@ func TestWhatsAppPairsAfterFirstConnectionRetriesStop(t *testing.T) { synctest.Wait() test.whatsapp.Command("unlink", func(_ string) { }) + synctest.Wait() require.False(t, test.whatsapp.isLinked()) @@ -316,6 +318,7 @@ func TestWhatsAppLogsEachChangeOfTheConnectionOnce(t *testing.T) { "<4>disconnected, retrying", "<6>connected again after=1m25s", }, strings.Split(strings.TrimSuffix(logs.String(), "\n"), "\n")) + require.NotContains(t, logs.String(), whatsappTestNumber) require.NotContains(t, logs.String(), whatsappTestLID) } @@ -663,6 +666,7 @@ func TestWhatsAppLogsNoOwnNumber(t *testing.T) { require.Eventually(t, func() bool { return logs.String() != "" }, 10*time.Second, time.Millisecond) + require.Equal(t, "<4>first connect failed, retrying: no route for OWN@s.whatsapp.net and OWN@lid\n", logs.String()) } diff --git a/whatsapp_voice_test.go b/whatsapp_voice_test.go index 305afdf..5210823 100644 --- a/whatsapp_voice_test.go +++ b/whatsapp_voice_test.go @@ -50,6 +50,7 @@ func TestWhatsAppVoiceRoutingSurvivesRestartWithoutDuplicateEntries(t *testing.T server.reply = func(w http.ResponseWriter, _ ModelFakeCall) { modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5)) } + first := newDaemonMessageTest(t, dir, server) whatsappTestSession(t, dir, true)