package main // 🤖 End-to-end test for the workstation bridge (`lchat agents run`, // internal/agents) against the real router: a stub `claude` binary stands in // for the CLI (emitting canned stream-json), so the test pins the whole loop // — conversational spawn, worktree creation, prompt attribution over stdin, // session.turn/status events landing encrypted in the thread, and // close-session-closes-worktree with the branch surviving. import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "time" "lchat/internal/agents" ) // stubClaude writes a shell script that speaks just enough stream-json: an // init line at boot, then one tool_use + one result per stdin line. Every // received stdin line is appended to logPath so the test can assert on what // the bridge actually sent (prompt attribution). func stubClaude(t *testing.T, logPath string) string { t.Helper() dir := t.TempDir() p := filepath.Join(dir, "claude") script := `#!/bin/sh printf '%s\n' '{"type":"system","subtype":"init","session_id":"cs-stub"}' while IFS= read -r line; do printf '%s\n' "$line" >> "` + logPath + `" printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"go test ./..."}}]}}' printf '%s\n' '{"type":"result","result":"done: fixed the thing","is_error":false}' done ` if err := os.WriteFile(p, []byte(script), 0o755); err != nil { t.Fatal(err) } return p } // gitRoot creates a repo with one commit, so worktree creation has a HEAD. func gitRoot(t *testing.T) string { t.Helper() // The worktree lands BESIDE the root, so give the root a parent dir the // test owns. root := filepath.Join(t.TempDir(), "repo") if err := os.MkdirAll(root, 0o755); err != nil { t.Fatal(err) } run := func(args ...string) { cmd := exec.Command("git", append([]string{"-C", root}, args...)...) cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v: %s", args, err, out) } } run("init", "-q") if err := os.WriteFile(filepath.Join(root, "README"), []byte("x"), 0o644); err != nil { t.Fatal(err) } run("add", "README") run("commit", "-q", "-m", "init") return root } // waitUntil polls until cond returns true or the deadline passes. func waitUntil(t *testing.T, what string, cond func() bool) { t.Helper() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if cond() { return } time.Sleep(50 * time.Millisecond) } t.Fatalf("timed out waiting for %s", what) } func TestAgentsBridgeEndToEnd(t *testing.T) { ts, _ := newTestServer(t) bridgeMember := joinForTest(t, ts.URL, "Claude") paul := joinForTest(t, ts.URL, "Paul") thread := groupForTest(t, ts.URL, bridgeMember, paul) root := gitRoot(t) promptLog := filepath.Join(t.TempDir(), "prompts.log") cfg := &agents.Config{ Groups: map[string]agents.GroupConfig{thread: {Roots: []string{root}}}, Command: stubClaude(t, promptLog), } b := agents.New(ts.URL, bridgeMember.Session, cfg, t.Logf) ready := make(chan struct{}, 1) b.OnReady = func() { ready <- struct{}{} } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go b.Run(ctx) select { case <-ready: case <-time.After(5 * time.Second): t.Fatal("bridge never connected") } // Conversational birth: "@claude …" with zero live sessions spawns a // scoped session in the group's default root and steers it. if err := paul.Send(thread, "@claude fix the flaky reminder test"); err != nil { t.Fatal(err) } events := func(typ string) []map[string]any { evs, err := paul.Events(thread, "session.") if err != nil { return nil } var out []map[string]any for _, e := range evs { if e["type"] == typ { out = append(out, e) } } return out } var spawn map[string]any waitUntil(t, "session.spawn", func() bool { if s := events("session.spawn"); len(s) > 0 { spawn = s[0] return true } return false }) if spawn["flavor"] != "scoped" || spawn["root"] != root || spawn["by"] != "Paul" { t.Errorf("spawn: %+v", spawn) } worktree, _ := spawn["worktree"].(string) if worktree == "" { t.Fatal("scoped session in a git root got no worktree") } if _, err := os.Stat(worktree); err != nil { t.Fatalf("worktree missing: %v", err) } // The stub echoes stdin: the prompt must arrive attributed to its speaker. waitUntil(t, "attributed prompt on stdin", func() bool { data, _ := os.ReadFile(promptLog) return strings.Contains(string(data), "Paul: fix the flaky reminder test") }) // The stub's canned result becomes a durable turn, and the board settles // idle. waitUntil(t, "session.turn", func() bool { for _, e := range events("session.turn") { if e["text"] == "done: fixed the thing" && e["session"] == spawn["session"] { return true } } return false }) waitUntil(t, "idle status", func() bool { sts := events("session.status") return len(sts) > 0 && sts[len(sts)-1]["state"] == "idle" }) // A second @claude message routes to the one live session — no new spawn. if err := paul.Send(thread, "@claude also check the store hydration"); err != nil { t.Fatal(err) } waitUntil(t, "second prompt steered", func() bool { data, _ := os.ReadFile(promptLog) return strings.Contains(string(data), "Paul: also check the store hydration") }) if got := len(events("session.spawn")); got != 1 { t.Errorf("spawn count = %d, want 1 (routing, not re-birth)", got) } // Closing from the board: the member's session.end makes the bridge end // the process and remove the worktree; the branch survives. id, _ := spawn["id"].(string) if err := paul.SendEvent(thread, map[string]any{"type": "session.end", "id": id, "session": spawn["session"], "reason": "closed"}); err != nil { t.Fatal(err) } waitUntil(t, "worktree removed", func() bool { _, err := os.Stat(worktree) return os.IsNotExist(err) }) out, err := exec.Command("git", "-C", root, "branch", "--list", "agents/*").Output() if err != nil { t.Fatal(err) } if !strings.Contains(string(out), "agents/") { t.Errorf("session branch did not survive worktree removal: %q", out) } } func TestAgentsBridgeIgnoresUnconfiguredGroups(t *testing.T) { ts, _ := newTestServer(t) bridgeMember := joinForTest(t, ts.URL, "Claude") paul := joinForTest(t, ts.URL, "Paul") thread := groupForTest(t, ts.URL, bridgeMember, paul) other := t.TempDir() cfg := &agents.Config{ // A different thread id is granted — THIS thread must get nothing. Groups: map[string]agents.GroupConfig{"th-other": {Roots: []string{other}}}, Command: "/bin/false", } b := agents.New(ts.URL, bridgeMember.Session, cfg, t.Logf) ready := make(chan struct{}, 1) b.OnReady = func() { ready <- struct{}{} } ctx, cancel := context.WithCancel(context.Background()) defer cancel() go b.Run(ctx) select { case <-ready: case <-time.After(5 * time.Second): t.Fatal("bridge never connected") } if err := paul.Send(thread, "@claude do things"); err != nil { t.Fatal(err) } time.Sleep(500 * time.Millisecond) evs, err := paul.Events(thread, "session.") if err != nil { t.Fatal(err) } if len(evs) != 0 { t.Errorf("unconfigured group got session events: %+v", evs) } }