]> git.giacomolli.uk - onechat/blobdiff - daemon_message_test.go
Require consent and review for autosquash
[onechat] / daemon_message_test.go
index cfb41c643908cb3ac80728d72e0526e0ea0acc32..0ef67fce60038084ef8d00bef6ab9fa16f7c1f0f 100644 (file)
@@ -304,6 +304,7 @@ func TestPreparedNotificationKeepsItsLogIDBeforeStartupWriters(t *testing.T) {
        testCases := []struct {
                name   string
                resume bool
+               child  bool
        }{
                {
                        name: "keeps its reservation before a startup user message",
@@ -312,10 +313,24 @@ func TestPreparedNotificationKeepsItsLogIDBeforeStartupWriters(t *testing.T) {
                        name:   "keeps its reservation before a worker recovery note",
                        resume: true,
                },
+               {
+                       name:  "keeps a child reservation before startup input",
+                       child: true,
+               },
+               {
+                       name:   "keeps a child reservation before a worker recovery note",
+                       child:  true,
+                       resume: true,
+               },
        }
 
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
+                       index := 0
+                       if tc.child {
+                               index = 1
+                       }
+
                        directory := t.TempDir()
                        server := newModelFake(t)
                        server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
@@ -324,9 +339,10 @@ func TestPreparedNotificationKeepsItsLogIDBeforeStartupWriters(t *testing.T) {
 
                        first := newDaemonMessageTest(t, directory, server)
                        input := &NotifyInput{
-                               Source: "check",
-                               ID:     "reserved",
-                               Text:   "A result with a prepared log ID.",
+                               AgentID: first.agents[index].agent.config.ID,
+                               Source:  "check",
+                               ID:      "reserved",
+                               Text:    "A result with a prepared log ID.",
                        }
 
                        receipt, err := first.messages.Notify(input)
@@ -340,35 +356,35 @@ func TestPreparedNotificationKeepsItsLogIDBeforeStartupWriters(t *testing.T) {
                        first.Close(t)
 
                        second := newDaemonMessageTest(t, directory, server)
-                       require.False(t, second.loops[0].waiting())
-                       require.Empty(t, second.events[0].Kinds())
+                       require.False(t, second.loops[index].waiting())
+                       require.Empty(t, second.events[index].Kinds())
 
                        if tc.resume {
-                               path := filepath.Join(second.agents[0].agent.dir, subagentWorkFileName)
+                               path := filepath.Join(second.agents[index].agent.dir, subagentWorkFileName)
                                require.NoError(t, os.WriteFile(path, []byte(`{"running":["BlueFox"]}`), 0o600))
-                               require.NoError(t, second.loops[0].runs.Resume(t.Context()))
+                               require.NoError(t, second.loops[index].runs.Resume(t.Context()))
 
-                               note, err := second.agents[0].memory.Message(1)
+                               note, err := second.agents[index].memory.Message(1)
                                require.NoError(t, err)
                                require.Equal(t, "note", note.Kind)
                                require.Contains(t, note.Text, "The daemon restarted, cutting BlueFox")
                        } else {
-                               submitted, err := second.loops[0].Submit(terminalSource, "A user message during startup.")
+                               submitted, err := second.loops[index].Submit(terminalSource, "A user message during startup.")
                                require.NoError(t, err)
                                require.Equal(t, 1, submitted.ID)
                        }
 
-                       restored, err := second.agents[0].memory.Message(id)
+                       restored, err := second.agents[index].memory.Message(id)
                        require.NoError(t, err)
                        require.Equal(t, "work", restored.Kind)
                        require.Equal(t, expected, restored.Text)
                        require.Len(t, second.Queue(t).Pending, 1)
 
-                       second.Start(t, 0)
-                       second.events[0].WaitFor(t, "idle", 1)
+                       second.Start(t, index)
+                       second.events[index].WaitFor(t, "idle", 1)
                        require.Empty(t, second.Queue(t).Pending)
                        require.Equal(t, 1, server.Calls())
-                       require.Equal(t, id, second.events[0].LastEvent("work").ID)
+                       require.Equal(t, id, second.events[index].LastEvent("work").ID)
 
                        duplicate, err := second.messages.Notify(input)
                        require.NoError(t, err)
@@ -398,6 +414,403 @@ func TestNotificationBatchMakesProgressWithEscapedControlCharacters(t *testing.T
        require.Empty(t, test.Queue(t).Pending)
 }
 
+func TestNotificationSelectorsBindOnePermanentIdentity(t *testing.T) {
+       server := newModelFake(t)
+       test := newDaemonMessageTest(t, t.TempDir(), server)
+       input := &NotifyInput{
+               AgentID: test.agents[1].agent.config.ID,
+               Source:  "check",
+               ID:      "targeted",
+               Text:    "A result for one child.",
+       }
+
+       accepted, err := test.messages.Notify(input)
+       require.NoError(t, err)
+
+       queue := test.Queue(t)
+       require.Equal(t, test.agents[1].agent.config.ID, queue.Pending[0].TargetID)
+       require.Equal(t, queue.Pending[0].TargetID, queue.Notifications[0].TargetID)
+       require.Equal(t, "Garden", queue.Pending[0].Target)
+       require.Zero(t, test.agents[0].memory.Len())
+       require.Zero(t, test.agents[1].memory.Len())
+       require.Zero(t, test.agents[2].memory.Len())
+
+       input.AgentID = strings.ToUpper(test.agents[1].agent.config.ID)
+       duplicate, err := test.messages.Notify(input)
+       require.NoError(t, err)
+       require.Equal(t, accepted.ID, duplicate.ID)
+       require.Equal(t, "duplicate", duplicate.Status)
+
+       input.AgentID = test.agents[2].agent.config.ID
+       _, err = test.messages.Notify(input)
+       require.ErrorContains(t, err, "conflicts")
+
+       input.AgentID = ""
+       _, err = test.messages.Notify(input)
+       require.ErrorContains(t, err, "conflicts")
+       require.Equal(t, queue, test.Queue(t))
+}
+
+func TestNotificationRejectsUnknownWorkerAndMalformedIdentities(t *testing.T) {
+       server := newModelFake(t)
+       test := newDaemonMessageTest(t, t.TempDir(), server)
+       test.loops[0].runs.subagents.draw = func() string {
+               return "BlueFox"
+       }
+
+       worker, err := test.loops[0].runs.subagents.Create(t.Context())
+       require.NoError(t, err)
+
+       testCases := []NotifyInput{
+               {AgentID: worker.name},
+               {AgentID: "Missing"},
+               {AgentID: "garden"},
+               {AgentID: "../Garden"},
+               {AgentID: strings.Repeat("f", 2*agentIDBytes)},
+               {AgentID: "invalid"},
+       }
+
+       for _, tc := range testCases {
+               tc.Source = "check"
+               tc.ID = "1"
+               tc.Text = "Unchanged data."
+               receipt, err := test.messages.Notify(&tc)
+               require.Error(t, err)
+               require.Empty(t, receipt.Kind)
+       }
+
+       require.Empty(t, test.messages.state.Pending)
+       require.Empty(t, test.messages.state.Notifications)
+       require.Zero(t, server.Calls())
+}
+
+func TestChildNotificationWaitsForTheWholeActiveChain(t *testing.T) {
+       server := newModelFake(t)
+       arrived := make(chan struct{})
+       release := make(chan struct{})
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               if call.number == 0 {
+                       close(arrived)
+                       <-release
+                       modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5))
+
+                       return
+               }
+
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
+       }
+
+       test := newDaemonMessageTest(t, t.TempDir(), server)
+       test.Start(t, 0, 1, 2)
+       require.NoError(t, test.messages.Tell("OneChat", "Garden", "Start the first chain."))
+       <-arrived
+
+       _, err := test.messages.Notify(&NotifyInput{
+               AgentID: test.agents[1].agent.config.ID,
+               Source:  "check",
+               ID:      "next-turn",
+               Text:    "Only the next child turn sees this.",
+       })
+       require.NoError(t, err)
+       require.Equal(t, 1, test.agents[1].memory.Len())
+       close(release)
+       test.events[1].WaitFor(t, "idle", 2)
+       require.Equal(t, 3, server.Calls())
+       require.NotContains(t, strings.Join(turnInputTexts(t, server.Call(1)), "\n"), "Only the next child turn")
+       require.Contains(t, strings.Join(turnInputTexts(t, server.Call(2)), "\n"), "Only the next child turn")
+       require.Equal(t, notifySource, test.events[1].LastEvent("work").Source)
+       require.Zero(t, test.agents[0].memory.Len())
+       require.Zero(t, test.agents[2].memory.Len())
+       require.Empty(t, test.Queue(t).Pending)
+}
+
+func TestNotificationStableIdentitySurvivesOfflineNameChange(t *testing.T) {
+       directory := t.TempDir()
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
+       }
+
+       first := newDaemonMessageTest(t, directory, server)
+       input := &NotifyInput{
+               AgentID: first.agents[1].agent.config.ID,
+               Source:  "check",
+               ID:      "rename-proof",
+               Text:    "A saved child result.",
+       }
+
+       receipt, err := first.messages.Notify(input)
+       require.NoError(t, err)
+
+       id := 0
+       first.messages.state.Pending[0].LogID = &id
+       require.NoError(t, first.messages.save())
+
+       envelope := first.Queue(t).Pending[0].Text
+       config := first.agents[1].agent.config
+       oldPath := first.agents[1].agent.dir
+       first.Close(t)
+
+       // Change only test fixtures to exercise recovery under the same
+       // identity.
+       newPath := filepath.Join(filepath.Dir(oldPath), "Orchard")
+       require.NoError(t, os.Rename(oldPath, newPath))
+
+       config.Name = "Orchard"
+       agentWriteTestConfig(t, newPath, config)
+
+       second := newDaemonMessageTest(t, directory, server)
+       child := second.messages.Child("OneChat", "Orchard")
+       require.NotNil(t, child)
+       require.False(t, child.waiting())
+
+       restored, err := child.memory.Message(id)
+       require.NoError(t, err)
+       require.Equal(t, envelope, restored.Text)
+
+       duplicate, err := second.messages.Notify(input)
+       require.NoError(t, err)
+       require.Equal(t, receipt.ID, duplicate.ID)
+       require.Equal(t, "duplicate", duplicate.Status)
+       require.NoError(t, second.messages.Notifications(child))
+       require.NoError(t, child.turn(t.Context()))
+       require.Empty(t, second.Queue(t).Pending)
+       require.Zero(t, second.agents[0].memory.Len())
+
+       second.Close(t)
+
+       third := newDaemonMessageTest(t, directory, server)
+       duplicate, err = third.messages.Notify(input)
+       require.NoError(t, err)
+       require.Equal(t, "duplicate", duplicate.Status)
+       require.Empty(t, third.Queue(t).Pending)
+       require.Equal(t, 1, server.Calls())
+}
+
+func TestMissingNotificationIdentityStopsStartupWithoutFallback(t *testing.T) {
+       directory := t.TempDir()
+       server := newModelFake(t)
+       first := newDaemonMessageTest(t, directory, server)
+       _, err := first.messages.Notify(&NotifyInput{
+               AgentID: first.agents[1].agent.config.ID,
+               Source:  "check",
+               ID:      "missing",
+               Text:    "Do not redirect this result.",
+       })
+       require.NoError(t, err)
+
+       path := first.messages.path
+       before, err := os.ReadFile(path)
+       require.NoError(t, err)
+
+       childPath := first.agents[1].agent.dir
+       first.Close(t)
+
+       require.NoError(t, os.RemoveAll(childPath))
+       // A new agent with the same name must not receive the old identity's
+       // work.
+       daemonChildConfig(t, directory, "Garden")
+
+       agents, err := openDaemonAgents(t.Context(), directory, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog())
+       require.NoError(t, err)
+
+       var loops []*TurnLoop
+
+       for _, agent := range agents {
+               loop, err := agent.Loop(nil, func(Event) {
+               })
+               require.NoError(t, err)
+
+               loops = append(loops, loop)
+       }
+
+       _, err = openDaemonMessages(loops)
+       require.ErrorContains(t, err, "notification target is missing")
+
+       after, err := os.ReadFile(path)
+       require.NoError(t, err)
+       require.Equal(t, before, after)
+       require.Zero(t, agents[0].memory.Len())
+       require.Zero(t, agents[1].memory.Len())
+       require.NoError(t, agents.Close())
+}
+
+func TestCompletedNotificationReceiptDoesNotRequireItsTargetAtStartup(t *testing.T) {
+       directory := t.TempDir()
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
+       }
+
+       first := newDaemonMessageTest(t, directory, server)
+       _, err := first.messages.Notify(&NotifyInput{
+               AgentID: first.agents[1].agent.config.ID,
+               Source:  "check",
+               ID:      "completed-child",
+               Text:    "A completed result.",
+       })
+       require.NoError(t, err)
+       require.NoError(t, first.messages.Notifications(first.loops[1]))
+       require.NoError(t, first.loops[1].turn(t.Context()))
+       require.Empty(t, first.Queue(t).Pending)
+
+       childPath := first.agents[1].agent.dir
+       first.Close(t)
+       require.NoError(t, os.RemoveAll(childPath))
+
+       second := newDaemonMessageTest(t, directory, server)
+       require.Empty(t, second.Queue(t).Pending)
+       require.Len(t, second.Queue(t).Notifications, 1)
+       require.Zero(t, second.agents[0].memory.Len())
+}
+
+func TestLegacyNotificationReceiptsStillBindTheRoot(t *testing.T) {
+       directory := t.TempDir()
+       server := newModelFake(t)
+       first := newDaemonMessageTest(t, directory, server)
+       input := &NotifyInput{
+               Source: "check",
+               ID:     "legacy",
+               Text:   "A root result.",
+       }
+
+       receipt, err := first.messages.Notify(input)
+       require.NoError(t, err)
+
+       first.messages.state.Pending[0].TargetID = ""
+       first.messages.state.Notifications[0].TargetID = ""
+       require.NoError(t, first.messages.save())
+
+       before, err := os.ReadFile(first.messages.path)
+       require.NoError(t, err)
+       first.Close(t)
+
+       second := newDaemonMessageTest(t, directory, server)
+       after, err := os.ReadFile(second.messages.path)
+       require.NoError(t, err)
+       require.Equal(t, before, after)
+
+       duplicate, err := second.messages.Notify(input)
+       require.NoError(t, err)
+       require.Equal(t, receipt.ID, duplicate.ID)
+       require.Equal(t, "duplicate", duplicate.Status)
+
+       input.AgentID = second.agents[0].agent.config.ID
+       duplicate, err = second.messages.Notify(input)
+       require.NoError(t, err)
+       require.Equal(t, "duplicate", duplicate.Status)
+
+       input.AgentID = second.agents[1].agent.config.ID
+       _, err = second.messages.Notify(input)
+       require.ErrorContains(t, err, "conflicts")
+}
+
+func TestChildNotificationRunsWhileTheRootIsBusy(t *testing.T) {
+       server := newModelFake(t)
+       arrived := make(chan struct{})
+       release := make(chan struct{})
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               if call.number == 0 {
+                       close(arrived)
+                       <-release
+               }
+
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
+       }
+
+       test := newDaemonMessageTest(t, t.TempDir(), server)
+       test.Start(t, 0, 1)
+
+       _, err := test.loops[0].Submit(terminalSource, "Keep the root busy.")
+       require.NoError(t, err)
+       <-arrived
+
+       _, err = test.messages.Notify(&NotifyInput{
+               AgentID: test.agents[1].agent.config.ID,
+               Source:  "check",
+               ID:      "independent",
+               Text:    "A result for the idle child.",
+       })
+       require.NoError(t, err)
+       test.events[1].WaitFor(t, "idle", 1)
+       require.True(t, test.loops[0].Busy())
+       require.Equal(t, 1, test.agents[0].memory.Len())
+       require.Empty(t, test.events[0].Last("work"))
+       close(release)
+       test.events[0].WaitFor(t, "idle", 1)
+       require.Empty(t, test.Queue(t).Pending)
+       require.Equal(t, 2, server.Calls())
+}
+
+func TestNotificationBatchesStayOrderedForEachChild(t *testing.T) {
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
+       }
+
+       test := newDaemonMessageTest(t, t.TempDir(), server)
+       test.agents[1].memory.summarizer = newFakeSummarizer()
+       test.agents[2].memory.summarizer = newFakeSummarizer()
+
+       for number := range 21 {
+               for index := 1; index <= 2; index++ {
+                       _, err := test.messages.Notify(&NotifyInput{
+                               AgentID: test.agents[index].agent.config.ID,
+                               Source:  "check",
+                               ID:      fmt.Sprintf("%d-%d", index, number),
+                               Text:    fmt.Sprintf("target-%d-result-%02d", index, number),
+                       })
+                       require.NoError(t, err)
+               }
+       }
+
+       test.Start(t, 0, 1, 2)
+       test.events[1].WaitFor(t, "idle", 2)
+       test.events[2].WaitFor(t, "idle", 2)
+       require.Equal(t, 4, server.Calls())
+       require.Zero(t, test.agents[0].memory.Len())
+       require.Empty(t, test.Queue(t).Pending)
+
+       for index := 1; index <= 2; index++ {
+               for number := range 21 {
+                       logged, err := test.agents[index].memory.Message(number)
+                       require.NoError(t, err)
+                       require.Contains(t, logged.Text, fmt.Sprintf("target-%d-result-%02d", index, number))
+               }
+       }
+}
+
+func TestChildNotificationReportsOnlyItsFinalReplyToTheRoot(t *testing.T) {
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               reply := ""
+               if call.body["model"] == "gpt-test-garden" {
+                       reply = "A result needs the user."
+               }
+
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(reply)}, 100, 0, 5))
+       }
+
+       test := newDaemonMessageTest(t, t.TempDir(), server)
+       test.Start(t, 0, 1, 2)
+
+       _, err := test.messages.Notify(&NotifyInput{
+               AgentID: test.agents[1].agent.config.ID,
+               Source:  "check",
+               ID:      "report",
+               Text:    "Private child event data.",
+       })
+       require.NoError(t, err)
+       test.events[1].WaitFor(t, "idle", 1)
+       test.events[0].WaitFor(t, "idle", 1)
+       require.Equal(t, 2, server.Calls())
+       require.Equal(t, "[Garden] A result needs the user.", test.events[0].Last("work"))
+       require.Equal(t, 1, test.events[0].Count("work"))
+       require.Equal(t, notifySource, test.events[1].LastEvent("work").Source)
+       require.Zero(t, test.agents[2].memory.Len())
+       require.Empty(t, test.Queue(t).Pending)
+}
+
 func TestParentMessageWakesChildAndFinalReplyWakesParent(t *testing.T) {
        server := newModelFake(t)
        server.reply = func(w http.ResponseWriter, call ModelFakeCall) {