| 1 | package main |
| 2 | |
| 3 | // 🤖 End-to-end test for the workstation bridge (`lchat agents run`, |
| 4 | // internal/agents) against the real router: a stub `claude` binary stands in |
| 5 | // for the CLI (emitting canned stream-json), so the test pins the whole loop |
| 6 | // — conversational spawn, worktree creation, prompt attribution over stdin, |
| 7 | // session.turn/status events landing encrypted in the thread, and |
| 8 | // close-session-closes-worktree with the branch surviving. |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "os" |
| 13 | "os/exec" |
| 14 | "path/filepath" |
| 15 | "strings" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "lchat/internal/agents" |
| 20 | ) |
| 21 | |
| 22 | // stubClaude writes a shell script that speaks just enough stream-json: an |
| 23 | // init line at boot, then one tool_use + one result per stdin line. Every |
| 24 | // received stdin line is appended to logPath so the test can assert on what |
| 25 | // the bridge actually sent (prompt attribution). |
| 26 | func stubClaude(t *testing.T, logPath string) string { |
| 27 | t.Helper() |
| 28 | dir := t.TempDir() |
| 29 | p := filepath.Join(dir, "claude") |
| 30 | script := `#!/bin/sh |
| 31 | printf '%s\n' '{"type":"system","subtype":"init","session_id":"cs-stub"}' |
| 32 | while IFS= read -r line; do |
| 33 | printf '%s\n' "$line" >> "` + logPath + `" |
| 34 | printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"go test ./..."}}]}}' |
| 35 | printf '%s\n' '{"type":"result","result":"done: fixed the thing","is_error":false}' |
| 36 | done |
| 37 | ` |
| 38 | if err := os.WriteFile(p, []byte(script), 0o755); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | return p |
| 42 | } |
| 43 | |
| 44 | // gitRoot creates a repo with one commit, so worktree creation has a HEAD. |
| 45 | func gitRoot(t *testing.T) string { |
| 46 | t.Helper() |
| 47 | // The worktree lands BESIDE the root, so give the root a parent dir the |
| 48 | // test owns. |
| 49 | root := filepath.Join(t.TempDir(), "repo") |
| 50 | if err := os.MkdirAll(root, 0o755); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | run := func(args ...string) { |
| 54 | cmd := exec.Command("git", append([]string{"-C", root}, args...)...) |
| 55 | cmd.Env = append(os.Environ(), |
| 56 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") |
| 57 | if out, err := cmd.CombinedOutput(); err != nil { |
| 58 | t.Fatalf("git %v: %v: %s", args, err, out) |
| 59 | } |
| 60 | } |
| 61 | run("init", "-q") |
| 62 | if err := os.WriteFile(filepath.Join(root, "README"), []byte("x"), 0o644); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | run("add", "README") |
| 66 | run("commit", "-q", "-m", "init") |
| 67 | return root |
| 68 | } |
| 69 | |
| 70 | // waitUntil polls until cond returns true or the deadline passes. |
| 71 | func waitUntil(t *testing.T, what string, cond func() bool) { |
| 72 | t.Helper() |
| 73 | deadline := time.Now().Add(10 * time.Second) |
| 74 | for time.Now().Before(deadline) { |
| 75 | if cond() { |
| 76 | return |
| 77 | } |
| 78 | time.Sleep(50 * time.Millisecond) |
| 79 | } |
| 80 | t.Fatalf("timed out waiting for %s", what) |
| 81 | } |
| 82 | |
| 83 | func TestAgentsBridgeEndToEnd(t *testing.T) { |
| 84 | ts, _ := newTestServer(t) |
| 85 | bridgeMember := joinForTest(t, ts.URL, "Claude") |
| 86 | paul := joinForTest(t, ts.URL, "Paul") |
| 87 | thread := groupForTest(t, ts.URL, bridgeMember, paul) |
| 88 | |
| 89 | root := gitRoot(t) |
| 90 | promptLog := filepath.Join(t.TempDir(), "prompts.log") |
| 91 | cfg := &agents.Config{ |
| 92 | Groups: map[string]agents.GroupConfig{thread: {Roots: []string{root}}}, |
| 93 | Command: stubClaude(t, promptLog), |
| 94 | } |
| 95 | |
| 96 | b := agents.New(ts.URL, bridgeMember.Session, cfg, t.Logf) |
| 97 | ready := make(chan struct{}, 1) |
| 98 | b.OnReady = func() { ready <- struct{}{} } |
| 99 | ctx, cancel := context.WithCancel(context.Background()) |
| 100 | defer cancel() |
| 101 | go b.Run(ctx) |
| 102 | select { |
| 103 | case <-ready: |
| 104 | case <-time.After(5 * time.Second): |
| 105 | t.Fatal("bridge never connected") |
| 106 | } |
| 107 | |
| 108 | // Conversational birth: "@claude …" with zero live sessions spawns a |
| 109 | // scoped session in the group's default root and steers it. |
| 110 | if err := paul.Send(thread, "@claude fix the flaky reminder test"); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | |
| 114 | events := func(typ string) []map[string]any { |
| 115 | evs, err := paul.Events(thread, "session.") |
| 116 | if err != nil { |
| 117 | return nil |
| 118 | } |
| 119 | var out []map[string]any |
| 120 | for _, e := range evs { |
| 121 | if e["type"] == typ { |
| 122 | out = append(out, e) |
| 123 | } |
| 124 | } |
| 125 | return out |
| 126 | } |
| 127 | var spawn map[string]any |
| 128 | waitUntil(t, "session.spawn", func() bool { |
| 129 | if s := events("session.spawn"); len(s) > 0 { |
| 130 | spawn = s[0] |
| 131 | return true |
| 132 | } |
| 133 | return false |
| 134 | }) |
| 135 | if spawn["flavor"] != "scoped" || spawn["root"] != root || spawn["by"] != "Paul" { |
| 136 | t.Errorf("spawn: %+v", spawn) |
| 137 | } |
| 138 | worktree, _ := spawn["worktree"].(string) |
| 139 | if worktree == "" { |
| 140 | t.Fatal("scoped session in a git root got no worktree") |
| 141 | } |
| 142 | if _, err := os.Stat(worktree); err != nil { |
| 143 | t.Fatalf("worktree missing: %v", err) |
| 144 | } |
| 145 | |
| 146 | // The stub echoes stdin: the prompt must arrive attributed to its speaker. |
| 147 | waitUntil(t, "attributed prompt on stdin", func() bool { |
| 148 | data, _ := os.ReadFile(promptLog) |
| 149 | return strings.Contains(string(data), "Paul: fix the flaky reminder test") |
| 150 | }) |
| 151 | |
| 152 | // The stub's canned result becomes a durable turn, and the board settles |
| 153 | // idle. |
| 154 | waitUntil(t, "session.turn", func() bool { |
| 155 | for _, e := range events("session.turn") { |
| 156 | if e["text"] == "done: fixed the thing" && e["session"] == spawn["session"] { |
| 157 | return true |
| 158 | } |
| 159 | } |
| 160 | return false |
| 161 | }) |
| 162 | waitUntil(t, "idle status", func() bool { |
| 163 | sts := events("session.status") |
| 164 | return len(sts) > 0 && sts[len(sts)-1]["state"] == "idle" |
| 165 | }) |
| 166 | |
| 167 | // A second @claude message routes to the one live session — no new spawn. |
| 168 | if err := paul.Send(thread, "@claude also check the store hydration"); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | waitUntil(t, "second prompt steered", func() bool { |
| 172 | data, _ := os.ReadFile(promptLog) |
| 173 | return strings.Contains(string(data), "Paul: also check the store hydration") |
| 174 | }) |
| 175 | if got := len(events("session.spawn")); got != 1 { |
| 176 | t.Errorf("spawn count = %d, want 1 (routing, not re-birth)", got) |
| 177 | } |
| 178 | |
| 179 | // Closing from the board: the member's session.end makes the bridge end |
| 180 | // the process and remove the worktree; the branch survives. |
| 181 | id, _ := spawn["id"].(string) |
| 182 | if err := paul.SendEvent(thread, map[string]any{"type": "session.end", "id": id, "session": spawn["session"], "reason": "closed"}); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | waitUntil(t, "worktree removed", func() bool { |
| 186 | _, err := os.Stat(worktree) |
| 187 | return os.IsNotExist(err) |
| 188 | }) |
| 189 | out, err := exec.Command("git", "-C", root, "branch", "--list", "agents/*").Output() |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | if !strings.Contains(string(out), "agents/") { |
| 194 | t.Errorf("session branch did not survive worktree removal: %q", out) |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | func TestAgentsBridgeIgnoresUnconfiguredGroups(t *testing.T) { |
| 199 | ts, _ := newTestServer(t) |
| 200 | bridgeMember := joinForTest(t, ts.URL, "Claude") |
| 201 | paul := joinForTest(t, ts.URL, "Paul") |
| 202 | thread := groupForTest(t, ts.URL, bridgeMember, paul) |
| 203 | |
| 204 | other := t.TempDir() |
| 205 | cfg := &agents.Config{ |
| 206 | // A different thread id is granted — THIS thread must get nothing. |
| 207 | Groups: map[string]agents.GroupConfig{"th-other": {Roots: []string{other}}}, |
| 208 | Command: "/bin/false", |
| 209 | } |
| 210 | b := agents.New(ts.URL, bridgeMember.Session, cfg, t.Logf) |
| 211 | ready := make(chan struct{}, 1) |
| 212 | b.OnReady = func() { ready <- struct{}{} } |
| 213 | ctx, cancel := context.WithCancel(context.Background()) |
| 214 | defer cancel() |
| 215 | go b.Run(ctx) |
| 216 | select { |
| 217 | case <-ready: |
| 218 | case <-time.After(5 * time.Second): |
| 219 | t.Fatal("bridge never connected") |
| 220 | } |
| 221 | |
| 222 | if err := paul.Send(thread, "@claude do things"); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | time.Sleep(500 * time.Millisecond) |
| 226 | evs, err := paul.Events(thread, "session.") |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if len(evs) != 0 { |
| 231 | t.Errorf("unconfigured group got session events: %+v", evs) |
| 232 | } |
| 233 | } |
| 234 | |