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}