eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
10import (
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).
26func stubClaude(t *testing.T, logPath string) string {
27 t.Helper()
28 dir := t.TempDir()
29 p := filepath.Join(dir, "claude")
30 script := `#!/bin/sh
31printf '%s\n' '{"type":"system","subtype":"init","session_id":"cs-stub"}'
32while 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}'
36done
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.
45func 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.
71func 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
83func 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
198func 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