]> git.giacomolli.uk - onechat/commitdiff
Select notification targets by stable ID
authorAnderson A. Giacomolli <redacted>
Wed, 11 Nov 2026 19:27:29 +0000 (16:27 -0300)
committerAnderson A. Giacomolli <redacted>
Wed, 11 Nov 2026 20:03:18 +0000 (17:03 -0300)
The notify command selects a permanent target with --agent-id and
defaults to root when the flag is absent. Agent names, unknown IDs,
empty IDs, and repeated flags are refused instead of redirecting
external data.

The local socket keeps acceptance receipts private and reports target
conflicts without waking another agent. Command help and isolated tests
cover explicit root IDs, child delivery, absent names, and the existing
untrusted-data and next-turn boundaries.

Issue: 44d8703

README.rst
command.go
command_test.go
daemon_message_test.go
daemon_test.go
notify.go
notify_test.go
socket.go

index 6b011a872c9ea2d777a7251d5efa0dd5c8617d02..3c458d64089be27b3fcf427208ccc47c13f1fcb7 100644 (file)
@@ -81,7 +81,8 @@ External events
 ===============
 
 Use ``onechat notify --source CHECK --id EVENT --text TEXT`` to send
 ===============
 
 Use ``onechat notify --source CHECK --id EVENT --text TEXT`` to send
-external data to the root at its next turn. Success means accepted, not
+external data to the root at its next turn. Add ``--agent-id ID`` to
+select a permanent child by its stable ID. Success means accepted, not
 processed. See ``onechat notify --help`` for the local test limits.
 
 User service
 processed. See ``onechat notify --help`` for the local test limits.
 
 User service
index e62c4b6edabb2b97813ceb1bc308195036917c3d..8b5f29e106bbe511acba63bd6d7183840be2670f 100644 (file)
@@ -19,8 +19,8 @@ Commands:
                            import WhatsApp history (stop the daemon first)
     -n                     print the plan without model calls
     --agent NAME           import into an existing direct child (default: root)
                            import WhatsApp history (stop the daemon first)
     -n                     print the plan without model calls
     --agent NAME           import into an existing direct child (default: root)
-  notify --source SOURCE --id ID --text TEXT
-                           send an external event to the root
+  notify [--agent-id ID] --source SOURCE --id ID --text TEXT
+                           send an external event (default: root)
   login                    log in to ChatGPT with a device code
   probe LOG COUNT [BUILDS] measure the compactor on a log, with real calls
   version                  show the build version
   login                    log in to ChatGPT with a device code
   probe LOG COUNT [BUILDS] measure the compactor on a log, with real calls
   version                  show the build version
index 73539c0be1183d88a5993c2b035efccc7dc298c2..a28a2a2fcecf9b1402bc853e8f28339e5fe43468 100644 (file)
@@ -28,9 +28,13 @@ func TestHelpListsTheImportTargetFlag(t *testing.T) {
        require.Contains(t, help, "--agent NAME           import into an existing direct child (default: root)")
 }
 
        require.Contains(t, help, "--agent NAME           import into an existing direct child (default: root)")
 }
 
-func TestHelpListsTheRootNotificationCommand(t *testing.T) {
-       require.Contains(t, commandOutput(t, "help"), "notify --source SOURCE --id ID --text TEXT")
-       require.Contains(t, commandOutput(t, "notify", "--help"), "not processed")
+func TestHelpListsTheStableNotificationTargetFlag(t *testing.T) {
+       require.Contains(t, commandOutput(t, "help"), "notify [--agent-id ID] --source SOURCE --id ID --text TEXT")
+
+       help := commandOutput(t, "notify", "--help")
+       require.Contains(t, help, "not processed")
+       require.Contains(t, help, "--agent-id ID")
+       require.NotContains(t, help, "--agent NAME")
 }
 
 func commandOutput(t *testing.T, args ...string) string {
 }
 
 func commandOutput(t *testing.T, args ...string) string {
index b401eb78cc7c90ee30e7071005909df35e21fe2f..0ef67fce60038084ef8d00bef6ab9fa16f7c1f0f 100644 (file)
@@ -780,6 +780,37 @@ func TestNotificationBatchesStayOrderedForEachChild(t *testing.T) {
        }
 }
 
        }
 }
 
+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) {
 func TestParentMessageWakesChildAndFinalReplyWakesParent(t *testing.T) {
        server := newModelFake(t)
        server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
index a32977890b31777e5075690a359bb5769f83ac52..5670f6fe429bfb3c27a44f94cdc2f002baa084d8 100644 (file)
@@ -85,6 +85,18 @@ func TestNotificationSocketRejectsMissingOrInvalidData(t *testing.T) {
                        ID:     "1",
                        Text:   "data",
                },
                        ID:     "1",
                        Text:   "data",
                },
+               {
+                       AgentID: "Garden",
+                       Source:  "check",
+                       ID:      "1",
+                       Text:    "data",
+               },
+               {
+                       AgentID: strings.Repeat("f", 2*agentIDBytes),
+                       Source:  "check",
+                       ID:      "1",
+                       Text:    "data",
+               },
        }
 
        for _, tc := range testCases {
        }
 
        for _, tc := range testCases {
@@ -102,6 +114,11 @@ func TestNotificationSocketRejectsMissingOrInvalidData(t *testing.T) {
        require.NoError(t, decoder.Decode(&event))
        require.Equal(t, "error", event.Kind)
 
        require.NoError(t, decoder.Decode(&event))
        require.Equal(t, "error", event.Kind)
 
+       _, err = io.WriteString(conn, `{"type":"notify","notify":{"agent":"Garden","source":"check","id":"1","text":"data"}}`+"\n")
+       require.NoError(t, err)
+       require.NoError(t, decoder.Decode(&event))
+       require.Equal(t, "error", event.Kind)
+
        require.Zero(t, server.Calls())
        require.NoError(t, conn.Close())
 }
        require.Zero(t, server.Calls())
        require.NoError(t, conn.Close())
 }
index 2071ce6a980c7ebffabbc79c55e67beba441a2ee..d4e4e7a68b10e70149d7ea0a775155b23e1ace6a 100644 (file)
--- a/notify.go
+++ b/notify.go
@@ -37,15 +37,18 @@ const (
        // time.
        notifyTimeout = 10 * time.Second
 
        // time.
        notifyTimeout = 10 * time.Second
 
-       notifyUsage = `Usage: onechat notify --source SOURCE --id ID --text TEXT
+       notifyUsage = `Usage: onechat notify [--agent-id ID] --source SOURCE --id ID --text TEXT
 
 
-Send untrusted external data to the root agent at its next turn boundary.
+Send untrusted external data at the selected agent's next turn boundary.
+Omit --agent-id for root. An explicit ID must identify the root or a permanent
+child, not a task worker. Names are not accepted.
 Success means durably accepted, not processed. Retry with the same source,
 Success means durably accepted, not processed. Retry with the same source,
-ID, and text after an uncertain result. Changed text for an existing key fails.
+ID, target identity, and text after an uncertain result. A changed target or
+text for an existing key fails.
 Text is limited to 32768 bytes. Source and ID are 1 to 128 ASCII letters,
 digits, dots, underscores, or hyphens. The local test interface keeps up to
 1024 receipts without expiry and rejects new keys when full.
 Text is limited to 32768 bytes. Source and ID are 1 to 128 ASCII letters,
 digits, dots, underscores, or hyphens. The local test interface keeps up to
 1024 receipts without expiry and rejects new keys when full.
-Child targets, receipt expiry, and processing status are not supported.
+Receipt expiry and processing status are not supported.
 `
 )
 
 `
 )
 
@@ -171,9 +174,10 @@ func notifyParse(args []string) (NotifyInput, error) {
        var input NotifyInput
 
        fields := map[string]*string{
        var input NotifyInput
 
        fields := map[string]*string{
-               "--source": &input.Source,
-               "--id":     &input.ID,
-               "--text":   &input.Text,
+               "--agent-id": &input.AgentID,
+               "--source":   &input.Source,
+               "--id":       &input.ID,
+               "--text":     &input.Text,
        }
 
        seen := make(map[string]bool)
        }
 
        seen := make(map[string]bool)
@@ -197,6 +201,10 @@ func notifyParse(args []string) (NotifyInput, error) {
                *field = value
        }
 
                *field = value
        }
 
+       if seen["--agent-id"] && input.AgentID == "" {
+               return input, errors.New("notification agent ID must not be empty")
+       }
+
        err := notifyValidate(input)
        if err != nil {
                return input, fmt.Errorf("validate notification: %w", err)
        err := notifyValidate(input)
        if err != nil {
                return input, fmt.Errorf("validate notification: %w", err)
index c0a565d620fc271df8bdf6380d44d599981d9996..0f60ad50e5caeb22ea640ff002adbae42c82764e 100644 (file)
@@ -5,6 +5,7 @@ import (
        "context"
        "encoding/json"
        "net"
        "context"
        "encoding/json"
        "net"
+       "net/http"
        "os"
        "path/filepath"
        "strings"
        "os"
        "path/filepath"
        "strings"
@@ -49,10 +50,96 @@ func TestNotifyCLIReachesOnlyTheRootThroughTheDaemon(t *testing.T) {
        require.Equal(t, receipt.ID, duplicate.ID)
        require.Equal(t, "duplicate", duplicate.Status)
 
        require.Equal(t, receipt.ID, duplicate.ID)
        require.Equal(t, "duplicate", duplicate.Status)
 
+       root, err := openAgent(filepath.Join(filepath.Dir(socket), daemonAgentDirName))
+       require.NoError(t, err)
+       out.Reset()
+       require.NoError(t, notify(t.Context(), append([]string{"--agent-id", root.config.ID}, args...), &out))
+       require.NoError(t, json.Unmarshal(out.Bytes(), &duplicate))
+       require.Equal(t, receipt.ID, duplicate.ID)
+       require.Equal(t, "duplicate", duplicate.Status)
+
        args[len(args)-1] = "Changed result."
        require.ErrorContains(t, notify(t.Context(), args, &out), "conflicts")
 }
 
        args[len(args)-1] = "Changed result."
        require.ErrorContains(t, notify(t.Context(), args, &out), "conflicts")
 }
 
+func TestNotifyCLISelectsOnlyThePermanentChildByID(t *testing.T) {
+       directory := daemonTestSocketDir(t)
+       daemonChildConfig(t, directory, "Garden")
+       daemonChildConfig(t, directory, "Library")
+
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
+       }
+
+       agents, err := openDaemonAgents(t.Context(), directory, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, discardLog())
+       require.NoError(t, err)
+
+       whatsapp, _, _ := whatsappStartTest(t, directory, newTestClock())
+       listener, err := daemonListenSocket(filepath.Join(directory, socketFileName))
+       require.NoError(t, err)
+
+       ctx, cancel := context.WithCancel(t.Context())
+       defer cancel()
+
+       done := make(chan error, 1)
+       go func() {
+               done <- daemonServe(ctx, listener, nil, whatsapp, agents[0].schedules, func(events func(Event)) (*TurnLoop, error) {
+                       return agents[0].Loop(whatsapp, events)
+               }, agents[1:]...)
+       }()
+
+       t.Cleanup(func() {
+               cancel()
+               require.NoError(t, <-done)
+               require.NoError(t, agents.Close())
+       })
+
+       t.Setenv("ONECHAT_DIR", directory)
+
+       var out bytes.Buffer
+
+       args := []string{"--agent-id", agents[1].agent.config.ID, "--source", "check", "--id", "child-1", "--text", "A result for only the child."}
+       require.NoError(t, notify(t.Context(), args, &out))
+
+       var accepted Event
+
+       require.NoError(t, json.Unmarshal(out.Bytes(), &accepted))
+       require.Equal(t, "accepted", accepted.Status)
+       require.Eventually(t, func() bool {
+               data, err := os.ReadFile(filepath.Join(agents[0].agent.dir, "messages.json"))
+               if err != nil {
+                       return false
+               }
+
+               var queue DaemonMessageFile
+
+               err = json.Unmarshal(data, &queue)
+
+               return err == nil && len(queue.Pending) == 0
+       }, 10*time.Second, time.Millisecond)
+       require.Equal(t, 1, server.Calls())
+       require.Equal(t, "gpt-test-garden", server.Call(0).body["model"])
+       require.Zero(t, agents[0].memory.Len())
+       require.Zero(t, agents[2].memory.Len())
+
+       out.Reset()
+       require.NoError(t, notify(t.Context(), args, &out))
+
+       var duplicate Event
+
+       require.NoError(t, json.Unmarshal(out.Bytes(), &duplicate))
+       require.Equal(t, "duplicate", duplicate.Status)
+       require.Equal(t, accepted.ID, duplicate.ID)
+
+       args[1] = agents[2].agent.config.ID
+       require.ErrorContains(t, notify(t.Context(), args, &out), "conflicts")
+
+       args[1] = strings.Repeat("f", 2*agentIDBytes)
+       require.ErrorContains(t, notify(t.Context(), args, &out), "identity is unknown")
+       require.Equal(t, 1, server.Calls())
+}
+
 func TestNotifyFlagsValidateBeforeConnecting(t *testing.T) {
        testCases := []struct {
                name string
 func TestNotifyFlagsValidateBeforeConnecting(t *testing.T) {
        testCases := []struct {
                name string
@@ -67,9 +154,21 @@ func TestNotifyFlagsValidateBeforeConnecting(t *testing.T) {
                        args: []string{"-source", "check", "--id", "1", "--text", "data"},
                },
                {
                        args: []string{"-source", "check", "--id", "1", "--text", "data"},
                },
                {
-                       name: "rejects child targets in the root-only slice",
+                       name: "rejects name selectors",
                        args: []string{"--agent", "Garden", "--source", "check", "--id", "1", "--text", "data"},
                },
                        args: []string{"--agent", "Garden", "--source", "check", "--id", "1", "--text", "data"},
                },
+               {
+                       name: "rejects an empty explicit agent ID",
+                       args: []string{"--agent-id=", "--source", "check", "--id", "1", "--text", "data"},
+               },
+               {
+                       name: "rejects malformed agent IDs",
+                       args: []string{"--agent-id=Garden", "--source", "check", "--id", "1", "--text", "data"},
+               },
+               {
+                       name: "rejects repeated agent IDs",
+                       args: []string{"--agent-id", strings.Repeat("a", 2*agentIDBytes), "--agent-id", strings.Repeat("a", 2*agentIDBytes), "--source", "check", "--id", "1", "--text", "data"},
+               },
                {
                        name: "rejects a flag without a value",
                        args: []string{"--source"},
                {
                        name: "rejects a flag without a value",
                        args: []string{"--source"},
@@ -99,10 +198,11 @@ func TestNotifyFlagsValidateBeforeConnecting(t *testing.T) {
                })
        }
 
                })
        }
 
-       input, err := notifyParse([]string{"--source=check", "--id=event.1", "--text=Ol\u00e1"})
+       input, err := notifyParse([]string{"--agent-id=" + strings.Repeat("a", 2*agentIDBytes), "--source=check", "--id=event.1", "--text=Ol\u00e1"})
        require.NoError(t, err)
        require.Equal(t, "check", input.Source)
        require.Equal(t, "event.1", input.ID)
        require.NoError(t, err)
        require.Equal(t, "check", input.Source)
        require.Equal(t, "event.1", input.ID)
+       require.Equal(t, strings.Repeat("a", 2*agentIDBytes), input.AgentID)
 }
 
 func TestNotifyHelpAndOfflineDaemon(t *testing.T) {
 }
 
 func TestNotifyHelpAndOfflineDaemon(t *testing.T) {
index 1205d8c69a1ad4a51da6f15971dc675f0356072e..dc78d94b674afeba868a321f32a555362e81eb29 100644 (file)
--- a/socket.go
+++ b/socket.go
@@ -26,8 +26,9 @@ const (
 // with the absolute output path in Text. The backup answer is a "backup" event
 // with the path and size, or an "error" event. An "image" request has an
 // absolute Path and the caption in Text. A "notify" request has external data
 // with the absolute output path in Text. The backup answer is a "backup" event
 // with the path and size, or an "error" event. An "image" request has an
 // absolute Path and the caption in Text. A "notify" request has external data
-// in Notify. Its private "notify" answer has a queue ID and an accepted or
-// duplicate Status, not a processing result.
+// in Notify. Its optional agent_id selects a permanent identity, not a name.
+// Its private "notify" answer has a queue ID and an accepted or duplicate
+// Status, not a processing result.
 type SocketRequest struct {
        Type   string       `json:"type"`
        Text   string       `json:"text,omitempty"`
 type SocketRequest struct {
        Type   string       `json:"type"`
        Text   string       `json:"text,omitempty"`