package subagents import ( "context" "errors" "fmt" "net" "os" "path/filepath" "runtime" "sync" "syscall" "testing" "time" ) // shortSocketDir returns a tempdir short enough that we can append // "in.sock" and still fit under the unix-socket path cap. The Go // runner's t.TempDir() lives under a long /var/folders/... path on // macOS which blows the 104-byte limit by itself, so the inbox tests // can't use it. func shortSocketDir(t *testing.T) string { t.Helper() if runtime.GOOS == "windows" { t.Skip("subagent inbox transport uses Unix-domain sockets; Windows support will use a named-pipe backend") } dir, err := os.MkdirTemp("/tmp", "zut-in-") if err != nil { t.Fatalf("mkdir: %v", err) } t.Cleanup(func() { os.RemoveAll(dir) }) return dir } // TestInboxRoundTrip is the happy path: child listens, supervisor // dials and sends three lines, child receives them in order, and // closing both sides is clean. Locks in the line-framed protocol. func TestInboxRoundTrip(t *testing.T) { path := filepath.Join(shortSocketDir(t), "in.sock") ln, err := Listen(path) if err != nil { t.Fatalf("Listen: %v", err) } defer ln.Close() got := collectFor(t, ln.Lines(), 4, time.Second) inbox := NewInbox(path) defer inbox.Close() want := []string{"user hello", "user world", "cancel"} for _, msg := range want { if err := inbox.sendBytes([]byte(msg + "\n")); err != nil { t.Fatalf("sendBytes(%q): %v", msg, err) } } if err := inbox.SendCommand(NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "round trip"})); err != nil { t.Fatalf("SendCommand: %v", err) } gotLines := <-got if len(gotLines) != len(want)+1 { t.Fatalf("got %d lines, want %d: %v", len(gotLines), len(want)+1, gotLines) } for i, w := range want { if gotLines[i] != w { t.Errorf("line %d = %q; want %q", i, gotLines[i], w) } } parsed, err := ParseCommand(gotLines[len(want)]) if err != nil { t.Fatalf("ParseCommand: %v", err) } var payload TurnStartPayload if err := parsed.DecodePayload(&payload); err != nil { t.Fatalf("DecodePayload: %v", err) } if payload.Prompt != "round trip" { t.Fatalf("decoded prompt = %q, want %q", payload.Prompt, "round trip") } } // TestInboxNotReady regression-tests the explicit "child hasn't // opened the socket yet" case. The TUI needs an error it can show // as "agent not listening yet" rather than a generic dial failure. func TestInboxNotReady(t *testing.T) { path := filepath.Join(shortSocketDir(t), "in.sock") inbox := NewInbox(path) defer inbox.Close() err := inbox.sendBytes([]byte("user hi\n")) if err == nil { t.Fatal("sendBytes on missing socket: want error") } if !errors.Is(err, ErrNotReady) { t.Fatalf("sendBytes err = %v; want ErrNotReady", err) } } func TestDialUnixContextNonPositiveTimeoutIsNotReady(t *testing.T) { path := filepath.Join(t.TempDir(), "existing") if err := os.WriteFile(path, nil, 0o600); err != nil { t.Fatal(err) } for _, timeout := range []time.Duration{0, -time.Millisecond} { _, err := dialUnixContext(context.Background(), path, timeout) if !errors.Is(err, ErrNotReady) { t.Fatalf("dial timeout %s error = %v, want ErrNotReady", timeout, err) } } } type blockingWriteConn struct { net.Conn started chan struct{} release chan struct{} startOnce sync.Once closeOnce sync.Once } func (c *blockingWriteConn) Write(data []byte) (int, error) { c.startOnce.Do(func() { close(c.started) }) <-c.release return c.Conn.Write(data) } func (c *blockingWriteConn) Close() error { c.closeOnce.Do(func() { close(c.release) }) return c.Conn.Close() } type notifyingContext struct { context.Context doneCalled chan struct{} once sync.Once } func (c *notifyingContext) Done() <-chan struct{} { c.once.Do(func() { close(c.doneCalled) }) return c.Context.Done() } func TestInboxSendContextCancelsBlockedWrite(t *testing.T) { local, remote := net.Pipe() defer remote.Close() conn := &blockingWriteConn{ Conn: local, started: make(chan struct{}), release: make(chan struct{}), } inbox := NewInbox("unused") inbox.conn = conn defer inbox.Close() ctx, cancel := context.WithCancel(context.Background()) sent := make(chan error, 1) go func() { sent <- inbox.sendBytesContext(ctx, []byte("follow-up\n")) }() select { case <-conn.started: case <-time.After(time.Second): t.Fatal("write did not start") } cancel() select { case err := <-sent: if !errors.Is(err, context.Canceled) { t.Fatalf("blocked write error = %v, want context canceled", err) } case <-time.After(time.Second): t.Fatal("blocked write did not honor context cancellation") } } func TestInboxSendContextCancelsWhileAnotherWriteIsBlocked(t *testing.T) { local, remote := net.Pipe() defer remote.Close() conn := &blockingWriteConn{ Conn: local, started: make(chan struct{}), release: make(chan struct{}), } inbox := NewInbox("unused") inbox.conn = conn defer inbox.Close() first := make(chan error, 1) go func() { first <- inbox.sendBytesContext(context.Background(), []byte("first\n")) }() select { case <-conn.started: case <-time.After(time.Second): t.Fatal("first write did not start") } base, cancel := context.WithCancel(context.Background()) secondCtx := ¬ifyingContext{Context: base, doneCalled: make(chan struct{})} second := make(chan error, 1) go func() { second <- inbox.sendBytesContext(secondCtx, []byte("second\n")) }() select { case <-secondCtx.doneCalled: case <-time.After(time.Second): t.Fatal("second write did not wait for the send semaphore") } cancel() select { case err := <-second: if !errors.Is(err, context.Canceled) { t.Fatalf("waiting write error = %v, want context canceled", err) } case <-time.After(time.Second): t.Fatal("waiting write did not honor context cancellation") } _ = conn.Close() select { case <-first: case <-time.After(time.Second): t.Fatal("first write did not unblock") } } // TestIsNoListenerErrMatchesECONNREFUSED documents the contract // that the user's screenshot regression depends on: any dial error // whose root cause is "the kernel refused the connect because nobody // is listening" must fold into ErrNotReady at the sendBytes layer, // so the TUI shows "agent not accepting input (press R to resume)" // instead of leaking the unix-socket path. We pattern-match via // errors.Is on syscall.ECONNREFUSED rather than string-matching the // error text so platform variants all hit the same branch. func TestIsNoListenerErrMatchesECONNREFUSED(t *testing.T) { if !isNoListenerErr(syscall.ECONNREFUSED) { t.Error("isNoListenerErr(ECONNREFUSED) = false; want true") } // Wrapped via fmt.Errorf — common form of the dial error. if !isNoListenerErr(fmt.Errorf("dial unix /tmp/x: %w", syscall.ECONNREFUSED)) { t.Error("isNoListenerErr(wrapped) = false; want true") } if isNoListenerErr(syscall.ECONNRESET) { t.Error("isNoListenerErr(ECONNRESET) = true; want false (unrelated errno)") } if isNoListenerErr(nil) { t.Error("isNoListenerErr(nil) = true; want false") } } // TestInboxNewSupervisorPreemptsOld documents that only one // supervisor connection at a time is honoured. The subagent design // assumes a single parent zut owns each agent; if a second // parent dials, the listener boots the first one. The first // supervisor's already-delivered messages still land in the // channel — what we're guarding against is silent dual ownership, // not message loss. func TestInboxNewSupervisorPreemptsOld(t *testing.T) { path := filepath.Join(shortSocketDir(t), "in.sock") ln, err := Listen(path) if err != nil { t.Fatalf("Listen: %v", err) } defer ln.Close() first := NewInbox(path) if err := first.sendBytes([]byte("user one\n")); err != nil { t.Fatalf("first send: %v", err) } // Wait for the listener to absorb "user one" before dialing the // second supervisor; otherwise we race against the connection // preemption and "user one" can be dropped (the first conn is // closed by Accept before its read loop processes the byte). waitN(t, ln.Lines(), 1, time.Second) second := NewInbox(path) defer second.Close() if err := second.sendBytes([]byte("user two\n")); err != nil { t.Fatalf("second send: %v", err) } secondLines := waitN(t, ln.Lines(), 1, time.Second) if len(secondLines) != 1 || secondLines[0] != "user two" { t.Fatalf("second supervisor not received: %v", secondLines) } // The original supervisor's connection was preempted by the // listener accepting the second dial. The next sendBytes on the // first handle sees a broken pipe and drops the cached connection; // the call after that redials successfully. Both behaviours // matter: the broken-pipe error must surface (so a real // supervisor knows it lost ownership) AND the next call must // recover so a flaky reconnect doesn't permanently wedge the // inbox. if err := first.sendBytes([]byte("user three\n")); err == nil { t.Fatal("expected broken-pipe error on stale connection") } if err := first.sendBytes([]byte("user four\n")); err != nil { t.Fatalf("first redial after eviction: %v", err) } } func waitN(t *testing.T, ch <-chan string, n int, timeout time.Duration) []string { t.Helper() var got []string deadline := time.After(timeout) for len(got) < n { select { case msg := <-ch: got = append(got, msg) case <-deadline: return got } } return got } // collectFor consumes up to n items from ch within timeout and // pushes the slice onto the returned channel. Used by the tests // above so they don't race against the listener's accept goroutine. func collectFor(t *testing.T, ch <-chan string, n int, timeout time.Duration) <-chan []string { t.Helper() out := make(chan []string, 1) go func() { var got []string deadline := time.After(timeout) for len(got) < n { select { case msg, ok := <-ch: if !ok { out <- got return } got = append(got, msg) case <-deadline: out <- got return } } out <- got }() return out } // TestInboxConcurrentSends serialises the sendBytes call so two // goroutines writing at once still produce two whole lines, never // a tearing of bytes between them. func TestInboxConcurrentSends(t *testing.T) { path := filepath.Join(shortSocketDir(t), "in.sock") ln, err := Listen(path) if err != nil { t.Fatalf("Listen: %v", err) } defer ln.Close() inbox := NewInbox(path) defer inbox.Close() var wg sync.WaitGroup const n = 50 wg.Add(n) for i := 0; i < n; i++ { go func(i int) { defer wg.Done() if err := inbox.sendBytes([]byte("user msg\n")); err != nil { t.Errorf("send %d: %v", i, err) } }(i) } wg.Wait() collected := collectFor(t, ln.Lines(), n, 2*time.Second) got := <-collected if len(got) != n { t.Fatalf("got %d lines, want %d", len(got), n) } for i, g := range got { if g != "user msg" { t.Fatalf("line %d garbled: %q", i, g) } } }