}
}
+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) {
// 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.
`
)
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)
*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)
"context"
"encoding/json"
"net"
+ "net/http"
"os"
"path/filepath"
"strings"
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
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"},
})
}
- 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) {