v0.29.1 d92a60f

manager_test.go RawHistoryFolder

1package extensions
2
3import (
4	"context"
5	"encoding/json"
6	"io"
7	"os"
8
9	"github.com/bnema/zut/packages/agent/extproto"
10	"path/filepath"
11	"runtime"
12	"strings"
13	"sync"
14	"testing"
15	"time"
16)
17
18func TestLifecycleWriteDropsWhenSynchronousWriterOwnsLock(t *testing.T) {
19	ext := &Extension{stdin: &nopWriteCloser{}}
20	ext.writeMu.Lock()
21	defer ext.writeMu.Unlock()
22
23	done := make(chan error, 1)
24	go func() { done <- ext.tryWriteLifecycleFrame([]byte("lifecycle")) }()
25	select {
26	case err := <-done:
27		if err == nil {
28			t.Fatal("lifecycle write unexpectedly acquired the synchronous writer lock")
29		}
30	case <-time.After(lifecycleWriteGrace + time.Second):
31		t.Fatal("lifecycle write remained blocked on the synchronous writer lock")
32	}
33}
34
35type nopWriteCloser struct{}
36
37func (*nopWriteCloser) Write(data []byte) (int, error) { return len(data), nil }
38func (*nopWriteCloser) Close() error                   { return nil }
39
40var _ io.WriteCloser = (*nopWriteCloser)(nil)
41
42// stubHooks records every callback so the test can assert on them.
43type stubHooks struct {
44	mu           sync.Mutex
45	notifies     []string
46	displays     []string
47	alerts       []extproto.AlertRequest
48	alertExts    []string
49	submits      []string
50	submitSlash  []string
51	clearNotes   []string
52	panels       []extproto.PanelSpec
53	panelExts    []string
54	chromeClears []string
55}
56
57func (s *stubHooks) Notify(name, level, message string) {
58	s.mu.Lock()
59	defer s.mu.Unlock()
60	s.notifies = append(s.notifies, name+":"+level+":"+message)
61}
62func (s *stubHooks) Alert(name string, alert extproto.AlertRequest) {
63	s.mu.Lock()
64	defer s.mu.Unlock()
65	s.alertExts = append(s.alertExts, name)
66	s.alerts = append(s.alerts, alert)
67}
68func (s *stubHooks) Submit(text string) {
69	s.mu.Lock()
70	defer s.mu.Unlock()
71	s.submits = append(s.submits, text)
72}
73func (s *stubHooks) SubmitSlash(text string) {
74	s.mu.Lock()
75	defer s.mu.Unlock()
76	s.submitSlash = append(s.submitSlash, text)
77}
78func (s *stubHooks) Insert(string) {}
79func (s *stubHooks) Display(name, text string) {
80	s.mu.Lock()
81	defer s.mu.Unlock()
82	s.displays = append(s.displays, name+":"+text)
83}
84func (s *stubHooks) ClearNotes(name string) {
85	s.mu.Lock()
86	defer s.mu.Unlock()
87	s.clearNotes = append(s.clearNotes, name)
88}
89func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
90	s.mu.Lock()
91	defer s.mu.Unlock()
92	s.panelExts = append(s.panelExts, extName)
93	s.panels = append(s.panels, spec)
94}
95func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
96func (s *stubHooks) ClosePanel(string, string)                            {}
97func (s *stubHooks) SetStatus(string, string, string, string)             {}
98func (s *stubHooks) SetWidget(string, string, string, string, []string)   {}
99func (s *stubHooks) ClearWidget(string, string)                           {}
100func (s *stubHooks) ClearExtensionChrome(name string) {
101	s.mu.Lock()
102	defer s.mu.Unlock()
103	s.chromeClears = append(s.chromeClears, name)
104}
105
106func TestDiscoverIgnoresDirectoriesWithoutManifest(t *testing.T) {
107	home := t.TempDir()
108	cwd := t.TempDir()
109
110	orphanedDataDir := filepath.Join(cwd, ".zut", "extensions", "example", "projects")
111	if err := os.MkdirAll(orphanedDataDir, 0o700); err != nil {
112		t.Fatal(err)
113	}
114	if err := os.WriteFile(filepath.Join(orphanedDataDir, "state.json"), []byte(`{}`), 0o600); err != nil {
115		t.Fatal(err)
116	}
117
118	globalExtDir := filepath.Join(home, "extensions", "example")
119	if err := os.MkdirAll(globalExtDir, 0o755); err != nil {
120		t.Fatal(err)
121	}
122	if err := os.WriteFile(filepath.Join(globalExtDir, "extension.json"), []byte(`{"name":"example"}`), 0o644); err != nil {
123		t.Fatal(err)
124	}
125	if err := os.WriteFile(filepath.Join(globalExtDir, "theme.json"), []byte(`{"name":"Example"}`), 0o644); err != nil {
126		t.Fatal(err)
127	}
128
129	mgr := New(home, cwd, "0.0.0-test", "", "", nil)
130	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
131		t.Fatalf("discover errors = %v, want none", errs)
132	}
133	if _, ok := mgr.ext["example"]; !ok {
134		t.Fatal("global extension was shadowed by a project directory without a manifest")
135	}
136}
137
138// writeMockExtension creates a minimal extension on disk that uses a
139// shell script (or batch file on windows) to drive the protocol. The
140// script reads commands from stdin and emits hard-coded responses,
141// exercising the manager's spawn/handshake/dispatch path without
142// needing the SDK.
143func writeMockExtension(t *testing.T, root string) {
144	t.Helper()
145	if runtime.GOOS == "windows" {
146		t.Skip("mock extension uses /bin/sh; skip on windows")
147	}
148
149	dir := filepath.Join(root, "mock")
150	if err := os.MkdirAll(dir, 0o755); err != nil {
151		t.Fatal(err)
152	}
153
154	// Shell script: emit hello, read frames, respond. Reads until
155	// stdin closes; tail's -F keeps the pipe alive long enough for
156	// the manager to send command_invoked.
157	script := `#!/bin/sh
158printf '%s\n' '{"type":"hello","protocol_version":2,"name":"mock","version":"0.0.1","capabilities":["commands"]}'
159printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}'
160while IFS= read -r line; do
161  case "$line" in
162    *'"type":"command_invoked"'*)
163      id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
164      case "$line" in
165        *'"name":"Ping"'*)
166          printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
167          ;;
168        *)
169          printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"error\":\"non-canonical command name\"}"
170          ;;
171      esac
172      ;;
173    *'"type":"shutdown"'*)
174      printf '%s\n' '{"type":"shutdown_ack"}'
175      exit 0
176      ;;
177  esac
178done
179`
180	scriptPath := filepath.Join(dir, "run.sh")
181	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
182		t.Fatal(err)
183	}
184
185	manifest := map[string]any{
186		"name":    "mock",
187		"version": "0.0.1",
188		"exec":    "./run.sh",
189	}
190	mfb, _ := json.Marshal(manifest)
191	if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
192		t.Fatal(err)
193	}
194}
195
196func TestDiscoverReportsExitStatusAndLogWhenExtensionExitsBeforeHello(t *testing.T) {
197	if runtime.GOOS == "windows" {
198		t.Skip("mock extension uses /bin/sh; skip on windows")
199	}
200
201	tmp := t.TempDir()
202	extDir := filepath.Join(tmp, "extensions", "broken")
203	if err := os.MkdirAll(extDir, 0o755); err != nil {
204		t.Fatal(err)
205	}
206	script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 23\n"
207	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
208		t.Fatal(err)
209	}
210	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"broken","exec":"./run.sh"}`), 0o644); err != nil {
211		t.Fatal(err)
212	}
213
214	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
215	errs := mgr.Discover(context.Background())
216	if len(errs) != 1 {
217		t.Fatalf("discover errors = %v, want one", errs)
218	}
219	logPath := filepath.Join(tmp, "logs", "ext-broken.log")
220	got := errs[0].Error()
221	if strings.Contains(got, "%!w") {
222		t.Fatalf("error contains formatting artifact: %s", got)
223	}
224	if !strings.Contains(got, "extension exited before hello: exit status 23") || !strings.Contains(got, logPath) {
225		t.Fatalf("error does not report exit status and identify stderr log: %s", got)
226	}
227	logData, err := os.ReadFile(logPath)
228	if err != nil {
229		t.Fatalf("read extension log: %v", err)
230	}
231	if !strings.Contains(string(logData), "compile failed") {
232		t.Fatalf("extension stderr missing from log: %s", logData)
233	}
234}
235
236func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {
237	if runtime.GOOS == "windows" {
238		t.Skip("mock extension uses Unix signals; skip on windows")
239	}
240
241	tmp := t.TempDir()
242	extDir := filepath.Join(tmp, "extensions", "signaled")
243	if err := os.MkdirAll(extDir, 0o755); err != nil {
244		t.Fatal(err)
245	}
246	script := "#!/bin/sh\nkill -TERM $$\n"
247	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
248		t.Fatal(err)
249	}
250	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"signaled","exec":"./run.sh"}`), 0o644); err != nil {
251		t.Fatal(err)
252	}
253
254	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
255	errs := mgr.Discover(context.Background())
256	if len(errs) != 1 {
257		t.Fatalf("discover errors = %v, want one", errs)
258	}
259	got := errs[0].Error()
260	if !strings.Contains(got, "extension exited before hello: signal:") || !strings.Contains(got, "ext-signaled.log") {
261		t.Fatalf("error does not report signal and identify stderr log: %s", got)
262	}
263}
264
265func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
266	if runtime.GOOS == "windows" {
267		t.Skip("mock extension uses /bin/sh; skip on windows")
268	}
269
270	tmp := t.TempDir()
271	extDir := filepath.Join(tmp, "broken")
272	if err := os.MkdirAll(extDir, 0o755); err != nil {
273		t.Fatal(err)
274	}
275	scriptPath := filepath.Join(extDir, "run.sh")
276	script := "#!/bin/sh\nprintf '%s\\n' 'not-json'\nwhile IFS= read -r line; do :; done\n"
277	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
278		t.Fatal(err)
279	}
280
281	ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
282	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
283	err := mgr.spawn(context.Background(), ext)
284	if err == nil || !strings.Contains(err.Error(), "parse hello") {
285		t.Fatalf("spawn error = %v, want parse hello failure", err)
286	}
287	if ext.cmd == nil || ext.cmd.ProcessState == nil {
288		t.Fatal("failed handshake process was not reaped")
289	}
290	if _, err := ext.logFile.WriteString("after cleanup"); err == nil {
291		t.Fatal("failed handshake log file was not closed")
292	}
293}
294
295func TestSpawnRejectsUnsupportedExtensionProtocolVersion(t *testing.T) {
296	if runtime.GOOS == "windows" {
297		t.Skip("mock extension uses /bin/sh; skip on windows")
298	}
299
300	tmp := t.TempDir()
301	extDir := filepath.Join(tmp, "broken")
302	if err := os.MkdirAll(extDir, 0o755); err != nil {
303		t.Fatal(err)
304	}
305	script := `#!/bin/sh
306printf '%s\n' '{"type":"hello","protocol_version":1,"name":"broken"}'
307while IFS= read -r line; do :; done
308`
309	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
310		t.Fatal(err)
311	}
312
313	ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
314	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
315	err := mgr.spawn(context.Background(), ext)
316	if err == nil || !strings.Contains(err.Error(), "unsupported extension protocol version 1") {
317		t.Fatalf("spawn error = %v", err)
318	}
319	if ext.cmd == nil || ext.cmd.ProcessState == nil {
320		t.Fatal("failed protocol handshake process was not reaped")
321	}
322}
323
324func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
325	tmp := t.TempDir()
326	extDir := filepath.Join(tmp, "extensions", "theme-only")
327	if err := os.MkdirAll(extDir, 0o755); err != nil {
328		t.Fatal(err)
329	}
330	manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
331	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
332		t.Fatal(err)
333	}
334	theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
335	if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
336		t.Fatal(err)
337	}
338
339	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
340	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
341		t.Fatalf("discover errors: %v", errs)
342	}
343	defer mgr.Stop(10 * time.Millisecond)
344
345	opts := mgr.ThemeOptions()
346	if len(opts) != 1 {
347		t.Fatalf("theme options = %d, want 1", len(opts))
348	}
349	if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
350		t.Fatalf("unexpected theme option: %#v", opts[0])
351	}
352	if !strings.Contains(opts[0].Description, "from extension theme-only") {
353		t.Fatalf("description missing extension source: %q", opts[0].Description)
354	}
355}
356
357func TestManifestBundlesSkillsWithSafePrecedence(t *testing.T) {
358	tmp := t.TempDir()
359	extRoot := filepath.Join(tmp, "extensions")
360	extDir := filepath.Join(extRoot, "bundle")
361	if err := os.MkdirAll(filepath.Join(extDir, "skills", "phases"), 0o755); err != nil {
362		t.Fatal(err)
363	}
364	if err := os.WriteFile(filepath.Join(extDir, "skills", "phases", "SKILL.md"), []byte("---\nname: phases\ndescription: bundled phases\n---\nbody\n"), 0o644); err != nil {
365		t.Fatal(err)
366	}
367	manifest := `{"name":"bundle","skills":["skills"],"description":"bundle"}`
368	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
369		t.Fatal(err)
370	}
371	if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(`{"name":"bundle","colors":{"dark":{"accent":1}}}`), 0o644); err != nil {
372		t.Fatal(err)
373	}
374
375	mgr := New(tmp, "", "0.0.0-test", "", "", &stubHooks{})
376	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
377		t.Fatalf("discover errors: %v", errs)
378	}
379	defer mgr.Stop(10 * time.Millisecond)
380	bundled := mgr.Skills()
381	if len(bundled) != 1 || bundled[0].Name != "phases" || bundled[0].Source != "extension bundle" {
382		t.Fatalf("bundled skills = %#v", bundled)
383	}
384	if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"../outside"}}); len(errs) == 0 {
385		t.Fatal("path traversal manifest was accepted")
386	}
387	for _, path := range []string{"", filepath.Join(string(filepath.Separator), "outside")} {
388		if _, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{path}}); len(errs) == 0 {
389			t.Fatalf("unsafe skill path %q was accepted", path)
390		}
391	}
392	if runtime.GOOS != "windows" {
393		outside := filepath.Join(tmp, "outside", "SKILL.md")
394		if err := os.MkdirAll(filepath.Dir(outside), 0o755); err != nil {
395			t.Fatal(err)
396		}
397		if err := os.WriteFile(outside, []byte("---\nname: escape\ndescription: outside\n---\nbody\n"), 0o644); err != nil {
398			t.Fatal(err)
399		}
400		unsafeDir := filepath.Join(extDir, "unsafe")
401		if err := os.MkdirAll(unsafeDir, 0o755); err != nil {
402			t.Fatal(err)
403		}
404		if err := os.Symlink(outside, filepath.Join(unsafeDir, "SKILL.md")); err != nil {
405			t.Fatal(err)
406		}
407		loaded, errs := loadManifestSkills(extDir, Manifest{Name: "bundle", Skills: []string{"unsafe"}})
408		if len(errs) == 0 || len(loaded) != 0 {
409			t.Fatalf("symlinked skill outside extension was accepted: skills=%#v errors=%v", loaded, errs)
410		}
411	}
412}
413
414func TestManagerSpawnAndInvoke(t *testing.T) {
415	tmp := t.TempDir()
416	extRoot := filepath.Join(tmp, "extensions")
417	if err := os.MkdirAll(extRoot, 0o755); err != nil {
418		t.Fatal(err)
419	}
420	writeMockExtension(t, extRoot)
421
422	hooks := &stubHooks{}
423	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
424
425	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
426		t.Fatalf("discover errors: %v", errs)
427	}
428	defer mgr.Stop(2 * time.Second)
429
430	// Give the extension a beat to send register_command frames after
431	// the hello handshake.
432	time.Sleep(150 * time.Millisecond)
433
434	cmds := mgr.Commands()
435	found := false
436	for _, c := range cmds {
437		if c.Name == "Ping" {
438			found = true
439			break
440		}
441	}
442	if !found {
443		t.Fatalf("expected canonical command 'Ping', got %#v", cmds)
444	}
445	if !mgr.HasCommand("PING") {
446		t.Fatal("HasCommand(\"PING\") = false")
447	}
448	if owner := mgr.CommandOwner("pInG"); owner != "mock" {
449		t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner)
450	}
451
452	resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second)
453	if err != nil {
454		t.Fatalf("invoke: %v", err)
455	}
456	if resp.Action != "display" {
457		t.Errorf("expected action=display, got %q", resp.Action)
458	}
459	if resp.Display != "pong" {
460		t.Errorf("expected display=pong, got %q", resp.Display)
461	}
462}
463
464func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
465	if runtime.GOOS == "windows" {
466		t.Skip("mock extension uses /bin/sh; skip on windows")
467	}
468
469	tmp := t.TempDir()
470	extRoot := filepath.Join(tmp, "extensions")
471	writeDiagExtension := func(name, script string) {
472		t.Helper()
473		dir := filepath.Join(extRoot, name)
474		if err := os.MkdirAll(dir, 0o755); err != nil {
475			t.Fatal(err)
476		}
477		if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
478			t.Fatal(err)
479		}
480		mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"})
481		if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
482			t.Fatal(err)
483		}
484	}
485
486	writeDiagExtension("a-first", `#!/bin/sh
487printf '%s\n' '{"type":"hello","protocol_version":2,"name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
488printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
489printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
490printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
491printf '%s\n' '{"type":"ready"}'
492while IFS= read -r line; do
493  case "$line" in
494    *'"type":"shutdown"'*) exit 0 ;;
495  esac
496done
497`)
498	writeDiagExtension("b-second", `#!/bin/sh
499printf '%s\n' '{"type":"hello","protocol_version":2,"name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
500printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
501printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
502printf '%s\n' '{"type":"ready"}'
503while IFS= read -r line; do
504  case "$line" in
505    *'"type":"shutdown"'*) exit 0 ;;
506  esac
507done
508`)
509
510	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{})
511	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
512		t.Fatalf("discover errors: %v", errs)
513	}
514	defer mgr.Stop(2 * time.Second)
515	mgr.WaitForReady(2 * time.Second)
516
517	diags := mgr.Diagnostics()
518	byName := map[string]ExtensionDiagnostic{}
519	for _, d := range diags {
520		byName[d.Name] = d
521	}
522
523	first := byName["a-first"]
524	if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") {
525		t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages)
526	}
527
528	var shadowedTool bool
529	var activeCommands, shadowedCommands int
530	var conflictMessage bool
531	for _, d := range diags {
532		if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
533			conflictMessage = true
534		}
535		for _, tool := range d.Tools {
536			if tool.Name == "shared" && !tool.Active {
537				shadowedTool = true
538			}
539		}
540		for _, command := range d.Commands {
541			if !strings.EqualFold(command.Name, "casetest") {
542				continue
543			}
544			if command.Active {
545				activeCommands++
546			} else {
547				shadowedCommands++
548			}
549		}
550	}
551	if !shadowedTool {
552		t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
553	}
554	if activeCommands != 1 || shadowedCommands != 1 {
555		t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags)
556	}
557	if !conflictMessage {
558		t.Fatalf("expected conflict diagnostic, got %#v", diags)
559	}
560}
561
562// TestSpontaneousSubmit verifies that an extension can submit a
563// non-empty prompt outside of any command response.
564func TestSpontaneousSubmit(t *testing.T) {
565	if runtime.GOOS == "windows" {
566		t.Skip("mock extension uses /bin/sh; skip on windows")
567	}
568
569	tmp := t.TempDir()
570	extDir := filepath.Join(tmp, "extensions", "submit-mock")
571	if err := os.MkdirAll(extDir, 0o755); err != nil {
572		t.Fatal(err)
573	}
574
575	script := `#!/bin/sh
576printf '%s\n' '{"type":"hello","protocol_version":2,"name":"submit-mock","version":"0.1","capabilities":["submit"]}'
577printf '%s\n' '{"type":"ready"}'
578printf '%s\n' '{"type":"submit","text":"  explain this repository briefly  "}'
579printf '%s\n' '{"type":"submit","text":"   "}'
580while IFS= read -r line; do
581  case "$line" in
582    *'"type":"shutdown"'*)
583      printf '%s\n' '{"type":"shutdown_ack"}'
584      exit 0
585      ;;
586  esac
587done
588`
589	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
590		t.Fatal(err)
591	}
592	mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"})
593	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
594		t.Fatal(err)
595	}
596
597	hooks := &stubHooks{}
598	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
599	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
600		t.Fatalf("discover errors: %v", errs)
601	}
602	defer mgr.Stop(2 * time.Second)
603
604	deadline := time.Now().Add(2 * time.Second)
605	for time.Now().Before(deadline) {
606		hooks.mu.Lock()
607		n := len(hooks.submits)
608		hooks.mu.Unlock()
609		if n > 0 {
610			break
611		}
612		time.Sleep(20 * time.Millisecond)
613	}
614
615	hooks.mu.Lock()
616	defer hooks.mu.Unlock()
617	if len(hooks.submits) != 1 {
618		t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits)
619	}
620	if hooks.submits[0] != "explain this repository briefly" {
621		t.Fatalf("Submit text = %q", hooks.submits[0])
622	}
623}
624
625// TestSpontaneousAlert verifies that an extension sending an alert frame
626// causes the manager to call hooks.Alert with the structured request.
627func TestSpontaneousAlert(t *testing.T) {
628	if runtime.GOOS == "windows" {
629		t.Skip("mock extension uses /bin/sh; skip on windows")
630	}
631
632	tmp := t.TempDir()
633	extDir := filepath.Join(tmp, "extensions", "alert-mock")
634	if err := os.MkdirAll(extDir, 0o755); err != nil {
635		t.Fatal(err)
636	}
637
638	script := `#!/bin/sh
639printf '%s\n' '{"type":"hello","protocol_version":2,"name":"alert-mock","version":"0.1","capabilities":["alerts"]}'
640printf '%s\n' '{"type":"ready"}'
641printf '%s\n' '{"type":"alert","kind":"bell","reason":"question_ready"}'
642while IFS= read -r line; do
643  case "$line" in
644    *'"type":"shutdown"'*)
645      printf '%s\n' '{"type":"shutdown_ack"}'
646      exit 0
647      ;;
648  esac
649done
650`
651	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
652		t.Fatal(err)
653	}
654	manifest, _ := json.Marshal(map[string]any{"name": "alert-mock", "exec": "./run.sh"})
655	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), manifest, 0o644); err != nil {
656		t.Fatal(err)
657	}
658
659	hooks := &stubHooks{}
660	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-test", hooks)
661	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
662		t.Fatalf("discover errors: %v", errs)
663	}
664	defer mgr.Stop(2 * time.Second)
665
666	deadline := time.Now().Add(2 * time.Second)
667	for time.Now().Before(deadline) {
668		hooks.mu.Lock()
669		n := len(hooks.alerts)
670		hooks.mu.Unlock()
671		if n > 0 {
672			break
673		}
674		time.Sleep(20 * time.Millisecond)
675	}
676
677	hooks.mu.Lock()
678	defer hooks.mu.Unlock()
679	if len(hooks.alerts) != 1 || len(hooks.alertExts) != 1 {
680		t.Fatalf("alerts = %#v, extensions = %#v; want one alert", hooks.alerts, hooks.alertExts)
681	}
682	if hooks.alertExts[0] != "alert-mock" {
683		t.Fatalf("alert extension = %q, want alert-mock", hooks.alertExts[0])
684	}
685	if got := hooks.alerts[0]; got.Kind != extproto.AlertKindBell || got.Reason != "question_ready" {
686		t.Fatalf("alert = %+v, want bell/question_ready", got)
687	}
688}
689
690// TestSpontaneousOpenPanel verifies that an extension sending an
691// open_panel frame outside of any command response causes the manager
692// to call hooks.OpenPanel with the correct PanelSpec fields.
693func TestSpontaneousOpenPanel(t *testing.T) {
694	if runtime.GOOS == "windows" {
695		t.Skip("mock extension uses /bin/sh; skip on windows")
696	}
697
698	tmp := t.TempDir()
699	extDir := filepath.Join(tmp, "extensions", "panel-mock")
700	if err := os.MkdirAll(extDir, 0o755); err != nil {
701		t.Fatal(err)
702	}
703
704	// Extension emits hello + ready, then immediately fires a
705	// spontaneous open_panel, then waits for shutdown.
706	script := `#!/bin/sh
707printf '%s\n' '{"type":"hello","protocol_version":2,"name":"panel-mock","version":"0.1","capabilities":["panels"]}'
708printf '%s\n' '{"type":"ready"}'
709printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
710while IFS= read -r line; do
711  case "$line" in
712    *'"type":"shutdown"'*)
713      printf '%s\n' '{"type":"shutdown_ack"}'
714      exit 0
715      ;;
716  esac
717done
718`
719	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
720		t.Fatal(err)
721	}
722	mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"})
723	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
724		t.Fatal(err)
725	}
726
727	hooks := &stubHooks{}
728	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
729	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
730		t.Fatalf("discover errors: %v", errs)
731	}
732	defer mgr.Stop(2 * time.Second)
733
734	// Give the extension time to flush its open_panel frame.
735	deadline := time.Now().Add(2 * time.Second)
736	for time.Now().Before(deadline) {
737		hooks.mu.Lock()
738		n := len(hooks.panels)
739		hooks.mu.Unlock()
740		if n > 0 {
741			break
742		}
743		time.Sleep(20 * time.Millisecond)
744	}
745
746	hooks.mu.Lock()
747	defer hooks.mu.Unlock()
748
749	if len(hooks.panels) == 0 {
750		t.Fatal("hooks.OpenPanel was never called")
751	}
752	spec := hooks.panels[0]
753	if spec.ID != "test-panel" {
754		t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID)
755	}
756	if spec.Title != "Hello Panel" {
757		t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title)
758	}
759	if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" {
760		t.Errorf("panel lines: want [line one line two], got %v", spec.Lines)
761	}
762	if spec.Footer != "esc close" {
763		t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer)
764	}
765	if hooks.panelExts[0] != "panel-mock" {
766		t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0])
767	}
768}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close