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}