package swarm import ( "context" "errors" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" ) // TestRunnerEndToEndWithStubChild is the integration test for the // new daemon-mode runner. It compiles the stubchild binary in // testdata/cmd/stubchild, points an execRunner at it, and drives // a Swarm through one Spawn + one SendUserTurn + Stop cycle. // // What this proves: // // - The default argv shape (swarmAgentArgs) is one the child can // actually parse — locks the shape against silent breakage. // - The stdout JSONL parser ingests events and writes them to the // durable log. // - applyEventToSink turns events into Activity / Transcript // updates the dashboard reads. // - The supervisor inbox dials the child's socket and a follow-up // line round-trips back as a user_message event. // - Stop closes the inbox AND cancels the child's context so the // stub exits cleanly. // // The test is skipped on platforms without unix sockets (Windows). func TestRunnerEndToEndWithStubChild(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("unix sockets not supported") } if testing.Short() { t.Skip("skip end-to-end runner test in -short mode") } exe := buildStubChild(t) root := t.TempDir() repo := t.TempDir() f := New(Config{ Root: root, RepoRoot: repo, NewRunner: func(a *Agent) Runner { return &execRunner{ agent: a, Command: swarmAgentArgs(swarmAgentArgsOpts{ Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task, Model: a.Model, Provider: a.Provider, }), } }, }) defer f.StopAll() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() a, err := f.Spawn(ctx, "first task") if err != nil { t.Fatalf("Spawn: %v", err) } // Wait until the durable log has at least one assistant_message // from the initial task. That confirms stdout→log→follower. waitFor := func(want string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { evs, _ := ReadEventLog(a.EventLogPath) for _, ev := range evs { if strings.Contains(eventText(ev), want) { return } } time.Sleep(20 * time.Millisecond) } evs, _ := ReadEventLog(a.EventLogPath) t.Fatalf("timed out waiting for %q in event log; got %d events:\n%s\n%s", want, len(evs), formatEvents(evs), dumpEventsVerbose(evs)) } waitFor("echo: first task") // Send a follow-up over the inbox. The stub echoes the text into // an assistant_message we can poll for in the log. if err := retrySend(f, a.ID, "user follow up", time.Second); err != nil { t.Fatalf("SendInput: %v", err) } waitFor("echo: follow up") // Shut the agent down gracefully via the inbox. if err := f.SendInput(a.ID, "shutdown"); err != nil && !errors.Is(err, ErrNotReady) { t.Fatalf("shutdown send: %v", err) } a.Wait() if got := a.Status(); got != StatusDone && got != StatusKilled { t.Fatalf("final status = %s; want done/killed", got) } } // retrySend exists because the inbox dial races against the child // opening the socket. Production callers handle ErrNotReady with a // status message; tests retry within a small window. func retrySend(f *Swarm, id, msg string, timeout time.Duration) error { deadline := time.Now().Add(timeout) var lastErr error for time.Now().Before(deadline) { err := f.SendInput(id, msg) if err == nil { return nil } lastErr = err if !errors.Is(err, ErrNotReady) { return err } time.Sleep(30 * time.Millisecond) } return lastErr } func eventText(ev Event) string { if ev.Type != "assistant_message" && ev.Type != "user_message" { return "" } content, _ := ev.Data["content"].([]any) var sb strings.Builder for _, c := range content { m, _ := c.(map[string]any) if t, _ := m["type"].(string); t == "text" { if txt, _ := m["text"].(string); txt != "" { sb.WriteString(txt) sb.WriteByte('\n') } } } return sb.String() } func dumpEventsVerbose(evs []Event) string { var sb strings.Builder for _, ev := range evs { sb.WriteString(ev.Type) sb.WriteString("\t") for k, v := range ev.Data { sb.WriteString(k) sb.WriteString("=") switch vv := v.(type) { case string: sb.WriteString(vv) default: sb.WriteString("<...>") } sb.WriteString(" ") } sb.WriteString("\n") } return sb.String() } func formatEvents(evs []Event) string { var sb strings.Builder for _, ev := range evs { sb.WriteString(ev.Type) sb.WriteString(" ") sb.WriteString(ev.Time.Format(time.RFC3339Nano)) sb.WriteString("\n") } return sb.String() } func buildStubChild(t *testing.T) string { t.Helper() out := filepath.Join(t.TempDir(), "stubchild") cmd := exec.Command("go", "build", "-o", out, "./testdata/cmd/stubchild") // Pass through the test runner's env so `go build` can find // HOME, PATH, GOCACHE, etc. CGO is disabled to keep the build // hermetic across machines. cmd.Env = append(os.Environ(), "CGO_ENABLED=0") if b, err := cmd.CombinedOutput(); err != nil { t.Fatalf("build stubchild: %v\n%s", err, b) } return out }