v0.15.0 6b178f8

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

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