From: Anderson A. Giacomolli Date: Wed, 11 Nov 2026 19:27:29 +0000 (-0300) Subject: Select notification targets by stable ID X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/42b4acd4ba70d3c12da4513d139717e592eefe4a Select notification targets by stable ID 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 --- diff --git a/README.rst b/README.rst index 6b011a8..3c458d6 100644 --- a/README.rst +++ b/README.rst @@ -81,7 +81,8 @@ External events =============== 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 diff --git a/command.go b/command.go index e62c4b6..8b5f29e 100644 --- a/command.go +++ b/command.go @@ -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) - 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 diff --git a/command_test.go b/command_test.go index 73539c0..a28a2a2 100644 --- a/command_test.go +++ b/command_test.go @@ -28,9 +28,13 @@ func TestHelpListsTheImportTargetFlag(t *testing.T) { 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 { diff --git a/daemon_message_test.go b/daemon_message_test.go index b401eb7..0ef67fc 100644 --- a/daemon_message_test.go +++ b/daemon_message_test.go @@ -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) { diff --git a/daemon_test.go b/daemon_test.go index a329778..5670f6f 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -85,6 +85,18 @@ func TestNotificationSocketRejectsMissingOrInvalidData(t *testing.T) { 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 { @@ -102,6 +114,11 @@ func TestNotificationSocketRejectsMissingOrInvalidData(t *testing.T) { 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()) } diff --git a/notify.go b/notify.go index 2071ce6..d4e4e7a 100644 --- a/notify.go +++ b/notify.go @@ -37,15 +37,18 @@ const ( // 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, -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. -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{ - "--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) @@ -197,6 +201,10 @@ func notifyParse(args []string) (NotifyInput, error) { *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) diff --git a/notify_test.go b/notify_test.go index c0a565d..0f60ad5 100644 --- a/notify_test.go +++ b/notify_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "net" + "net/http" "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) + 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") } +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 @@ -67,9 +154,21 @@ func TestNotifyFlagsValidateBeforeConnecting(t *testing.T) { 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"}, }, + { + 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"}, @@ -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.Equal(t, strings.Repeat("a", 2*agentIDBytes), input.AgentID) } func TestNotifyHelpAndOfflineDaemon(t *testing.T) { diff --git a/socket.go b/socket.go index 1205d8c..dc78d94 100644 --- 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 -// 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"`