package main import ( "context" "io" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/require" ) func TestDaemonOpensAndRunsTheRootAndTwoChildren(t *testing.T) { dir := t.TempDir() daemonChildConfig(t, dir, "Garden") daemonChildConfig(t, dir, "Library") server := newModelFake(t) clock := newTestClock() agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, clock.Now, discardLog()) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, agents.Close()) }) require.Len(t, agents, 3) require.Equal(t, []string{"Garden", "Library"}, agents.ChildNames()) for _, agent := range agents { at := clock.Now().Add(time.Minute).Format(scheduleAtLayout) _, err = agent.schedules.Set("Record the decision for "+agent.agent.config.Name, &at, nil) require.NoError(t, err) } clock.now = clock.now.Add(time.Minute) whatsapp, _, _ := whatsappStartTest(t, dir, clock) listener, err := daemonListenSocket(filepath.Join(dir, 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(notify func(Event)) (*TurnLoop, error) { return agents[0].Loop(whatsapp, notify) }, agents[1:]...) }() t.Cleanup(func() { cancel() require.NoError(t, <-done) }) require.Eventually(t, func() bool { for _, agent := range agents { if agent.memory.Len() != 2 { return false } } return true }, 5*time.Second, time.Millisecond) require.Equal(t, 3, server.Calls()) keys := make(map[string]bool) for i := range server.Calls() { call := server.Call(i) keys[call.body["prompt_cache_key"].(string)] = true } require.Len(t, keys, 3) for _, agent := range agents { message, err := agent.memory.Message(0) require.NoError(t, err) require.Contains(t, message.Text, "Record the decision for "+agent.agent.config.Name) require.True(t, keys[agent.agent.CacheKey()]) usage := modelReadUsage(t, filepath.Join(agent.agent.dir, "usage.jsonl")) require.Len(t, usage, 1) require.Equal(t, "first", usage[0].Turn) require.Equal(t, agent.agent.config.Model, usage[0].Model) } terminal := terminalStartTestClient(t, listener.Addr().String()) terminal.WaitFor(t, "[connected to OneChat]") require.NotContains(t, terminal.Output(), "Garden") require.NotContains(t, terminal.Output(), "Library") } func TestDaemonReadyLineNamesTheChildren(t *testing.T) { dir := t.TempDir() daemonChildConfig(t, dir, "Garden") daemonChildConfig(t, dir, "Library") t.Setenv("ONECHAT_DIR", dir) var out SyncBuffer ctx, cancel := context.WithCancel(t.Context()) defer cancel() done := make(chan error, 1) go func() { done <- daemon(ctx, &out) }() t.Cleanup(func() { cancel() require.NoError(t, <-done) }) require.Eventually(t, func() bool { return strings.Contains(out.String(), "children=[Garden Library]") }, 5*time.Second, time.Millisecond) } func TestChildCallsShareTheirPromptAndToolsWithCompaction(t *testing.T) { dir := t.TempDir() daemonChildConfig(t, dir, "Garden") server := newModelFake(t) agents, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog()) require.NoError(t, err) defer func() { require.NoError(t, agents.Close()) }() child := agents[1] loop, err := child.Loop(nil, func(Event) { }) require.NoError(t, err) require.Nil(t, loop.tools.whatsapp) require.Contains(t, loop.tools.run(t.Context(), "whatsapp_send", `{"id":"fake","chat":"fake","text":"draft"}`), "unknown tool") _, err = loop.Submit("parent", strings.Repeat("Keep a garden decision. ", nodeBytes)) require.NoError(t, err) require.NoError(t, loop.turn(t.Context())) require.NoError(t, child.memory.Settle(t.Context())) require.GreaterOrEqual(t, server.Calls(), 2) first := server.Call(0) for i := range server.Calls() { call := server.Call(i) require.Equal(t, child.agent.CacheKey(), call.body["prompt_cache_key"]) require.Equal(t, first.body["instructions"], call.body["instructions"]) require.Equal(t, first.body["tools"], call.body["tools"]) require.NotContains(t, string(call.raw), "whatsapp_send") require.NotContains(t, string(call.raw), "whatsapp_find") } usage := modelReadUsage(t, child.client.UsagePath()) require.GreaterOrEqual(t, len(usage), 2) var turns, nodes int for _, record := range usage { if record.Node != nil { nodes++ } else { turns++ } } require.Positive(t, nodes) require.Equal(t, 1, turns) } func TestBrokenChildConfigStopsDaemonStartup(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, daemonAgentDirName, "children", "Garden") require.NoError(t, os.MkdirAll(path, 0o700)) require.NoError(t, os.WriteFile(filepath.Join(path, "config.json"), []byte(`{"broken":true}`), 0o600)) t.Setenv("ONECHAT_DIR", dir) err := daemon(t.Context(), io.Discard) require.ErrorContains(t, err, `open child "Garden"`) require.ErrorContains(t, err, `unknown field "broken"`) _, err = os.Stat(filepath.Join(dir, socketFileName)) require.ErrorIs(t, err, os.ErrNotExist) } func TestInvalidChildRelationshipsStopStartup(t *testing.T) { server := newModelFake(t) testCases := []struct { name string change func(*testing.T, string) cause string }{ { name: "missing config is not created", change: func(t *testing.T, path string) { require.NoError(t, os.Remove(filepath.Join(path, "config.json"))) }, cause: `child "Garden" config:`, }, { name: "wrong parent is rejected", change: func(t *testing.T, path string) { agent, err := openAgent(path) require.NoError(t, err) config := agent.config config.Parent = "Other" agentWriteTestConfig(t, path, config) }, cause: `needs name "Garden" and parent "OneChat"`, }, { name: "a grandchild is rejected", change: func(t *testing.T, path string) { require.NoError(t, os.MkdirAll(filepath.Join(path, "children", "Nested"), 0o700)) }, cause: "children cannot have children", }, { name: "a reused ID is rejected", change: func(t *testing.T, path string) { root, err := openAgent(filepath.Dir(filepath.Dir(path))) require.NoError(t, err) child, err := openAgent(path) require.NoError(t, err) config := child.config config.ID = root.config.ID agentWriteTestConfig(t, path, config) }, cause: "must have its own name and ID", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() path := daemonChildConfig(t, dir, "Garden") tc.change(t, path) _, err := openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, time.Now, discardLog()) require.ErrorContains(t, err, tc.cause) }) } }