1package extensions
2
3import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "runtime"
9 "sync"
10 "testing"
11 "time"
12)
13
14// stubHooks records every callback so the test can assert on them.
15type stubHooks struct {
16 mu sync.Mutex
17 notifies []string
18 displays []string
19}
20
21func (s *stubHooks) Notify(name, level, message string) {
22 s.mu.Lock()
23 defer s.mu.Unlock()
24 s.notifies = append(s.notifies, name+":"+level+":"+message)
25}
26func (s *stubHooks) Submit(string) {}
27func (s *stubHooks) Insert(string) {}
28func (s *stubHooks) Display(name, text string) {
29 s.mu.Lock()
30 defer s.mu.Unlock()
31 s.displays = append(s.displays, name+":"+text)
32}
33
34// writeMockExtension creates a minimal extension on disk that uses a
35// shell script (or batch file on windows) to drive the protocol. The
36// script reads commands from stdin and emits hard-coded responses,
37// exercising the manager's spawn/handshake/dispatch path without
38// needing the SDK.
39func writeMockExtension(t *testing.T, root string) {
40 t.Helper()
41 if runtime.GOOS == "windows" {
42 t.Skip("mock extension uses /bin/sh; skip on windows")
43 }
44
45 dir := filepath.Join(root, "mock")
46 if err := os.MkdirAll(dir, 0o755); err != nil {
47 t.Fatal(err)
48 }
49
50 // Shell script: emit hello, read frames, respond. Reads until
51 // stdin closes; tail's -F keeps the pipe alive long enough for
52 // the manager to send command_invoked.
53 script := `#!/bin/sh
54printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
55printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}'
56while IFS= read -r line; do
57 case "$line" in
58 *'"type":"command_invoked"'*)
59 id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
60 printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
61 ;;
62 *'"type":"shutdown"'*)
63 printf '%s\n' '{"type":"shutdown_ack"}'
64 exit 0
65 ;;
66 esac
67done
68`
69 scriptPath := filepath.Join(dir, "run.sh")
70 if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
71 t.Fatal(err)
72 }
73
74 manifest := map[string]any{
75 "name": "mock",
76 "version": "0.0.1",
77 "exec": "./run.sh",
78 }
79 mfb, _ := json.Marshal(manifest)
80 if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
81 t.Fatal(err)
82 }
83}
84
85func TestManagerSpawnAndInvoke(t *testing.T) {
86 tmp := t.TempDir()
87 extRoot := filepath.Join(tmp, "extensions")
88 if err := os.MkdirAll(extRoot, 0o755); err != nil {
89 t.Fatal(err)
90 }
91 writeMockExtension(t, extRoot)
92
93 hooks := &stubHooks{}
94 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
95
96 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
97 t.Fatalf("discover errors: %v", errs)
98 }
99 defer mgr.Stop(2 * time.Second)
100
101 // Give the extension a beat to send register_command frames after
102 // the hello handshake.
103 time.Sleep(150 * time.Millisecond)
104
105 cmds := mgr.Commands()
106 if len(cmds) != 1 || cmds[0].Name != "ping" {
107 t.Fatalf("expected one command 'ping', got %#v", cmds)
108 }
109 if !mgr.HasCommand("ping") {
110 t.Fatal("HasCommand(\"ping\") = false")
111 }
112
113 resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second)
114 if err != nil {
115 t.Fatalf("invoke: %v", err)
116 }
117 if resp.Action != "display" {
118 t.Errorf("expected action=display, got %q", resp.Action)
119 }
120 if resp.Display != "pong" {
121 t.Errorf("expected display=pong, got %q", resp.Display)
122 }
123}