+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)
+}
+