1package ext
2
3import (
4 "bufio"
5 "encoding/json"
6 "errors"
7 "io"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/bnema/zut/packages/agent/extproto"
13)
14
15// ---------- test harness ----------
16
17// extHarness wires an Extension to io.Pipe pairs so a test can play
18// the role of the host: write host→ext frames, read ext→host frames.
19// The scanner runs in a permanent background goroutine and delivers
20// frames over a buffered channel, avoiding the deadlock that would
21// occur if the test goroutine alternated between writing and reading
22// a synchronous pipe.
23type extHarness struct {
24 ext *Extension
25 hostW *io.PipeWriter // test writes here → ext reads as stdin
26 frames chan rawFrame // ext→host frames delivered here
27}
28
29type rawFrame struct {
30 hdr extproto.Frame
31 raw []byte
32}
33
34func newHarness(name string) *extHarness {
35 extStdinR, extStdinW := io.Pipe()
36 extStdoutR, extStdoutW := io.Pipe()
37
38 e := New(name, "0.0.0-test")
39 e.in = extStdinR
40 e.out = extStdoutW
41 e.stderr = io.Discard
42
43 h := &extHarness{
44 ext: e,
45 hostW: extStdinW,
46 frames: make(chan rawFrame, 64),
47 }
48
49 // Background reader: scan ext's stdout and push every frame into
50 // the channel so the test goroutine never needs to block on the pipe.
51 go func() {
52 scanner := bufio.NewScanner(extStdoutR)
53 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
54 for scanner.Scan() {
55 b := scanner.Bytes()
56 cp := make([]byte, len(b))
57 copy(cp, b)
58 var f extproto.Frame
59 json.Unmarshal(cp, &f)
60 h.frames <- rawFrame{f, cp}
61 }
62 close(h.frames)
63 }()
64
65 return h
66}
67
68func (h *extHarness) startRun(t *testing.T) {
69 t.Helper()
70 runDone := make(chan error, 1)
71 go func() { runDone <- h.ext.Run() }()
72 t.Cleanup(func() {
73 if err := h.hostW.Close(); err != nil && !errors.Is(err, io.ErrClosedPipe) {
74 t.Errorf("close extension input: %v", err)
75 }
76 select {
77 case err := <-runDone:
78 if err != nil {
79 t.Errorf("extension Run: %v", err)
80 }
81 case <-time.After(2 * time.Second):
82 t.Error("extension Run did not exit after closing host input")
83 }
84 })
85}
86
87// next returns the next frame, timing out after 2 s.
88func (h *extHarness) next(t *testing.T) rawFrame {
89 t.Helper()
90 select {
91 case f, ok := <-h.frames:
92 if !ok {
93 t.Fatal("frame channel closed (ext stdout EOF)")
94 }
95 return f
96 case <-time.After(2 * time.Second):
97 t.Fatal("timeout waiting for frame from extension")
98 return rawFrame{}
99 }
100}
101
102// drainUntil reads frames until one with type == want arrives.
103func (h *extHarness) drainUntil(t *testing.T, want string) rawFrame {
104 t.Helper()
105 deadline := time.NewTimer(2 * time.Second)
106 defer deadline.Stop()
107 for {
108 select {
109 case f, ok := <-h.frames:
110 if !ok {
111 t.Fatalf("frame channel closed before seeing %q", want)
112 }
113 if f.hdr.Type == want {
114 return f
115 }
116 case <-deadline.C:
117 t.Fatalf("timeout waiting for frame type %q", want)
118 return rawFrame{}
119 }
120 }
121}
122
123// sendToExt writes a host→ext frame.
124func (h *extHarness) sendToExt(t *testing.T, v any) {
125 t.Helper()
126 b, err := extproto.Encode(v)
127 if err != nil {
128 t.Fatalf("encode: %v", err)
129 }
130 if _, err := h.hostW.Write(b); err != nil {
131 t.Fatalf("write to ext: %v", err)
132 }
133}
134
135// handshake performs the hello / hello_ack exchange and drains frames
136// until "ready".
137func (h *extHarness) handshake(t *testing.T) {
138 t.Helper()
139 f := h.next(t)
140 if f.hdr.Type != "hello" {
141 t.Fatalf("expected hello, got %q", f.hdr.Type)
142 }
143 var hello extproto.HelloFromExt
144 if err := json.Unmarshal(f.raw, &hello); err != nil {
145 t.Fatalf("unmarshal hello: %v", err)
146 }
147 if hello.ProtocolVersion != extproto.ProtocolVersion {
148 t.Fatalf("hello protocol version = %d, want %d", hello.ProtocolVersion, extproto.ProtocolVersion)
149 }
150 h.sendToExt(t, extproto.HelloAckFromHost{
151 Type: "hello_ack",
152 ProtocolVersion: extproto.ProtocolVersion,
153 ZutVersion: "0.0.0-test",
154 Provider: "anthropic",
155 Model: "claude-test",
156 })
157 for {
158 f := h.next(t)
159 if f.hdr.Type == "ready" {
160 return
161 }
162 }
163}
164
165// ---------- tests ----------
166
167func TestRunRejectsUnsupportedHostProtocolVersion(t *testing.T) {
168 h := newHarness("versioned-ext")
169 runDone := make(chan error, 1)
170 go func() { runDone <- h.ext.Run() }()
171 t.Cleanup(func() { _ = h.hostW.Close() })
172
173 if frame := h.next(t); frame.hdr.Type != "hello" {
174 t.Fatalf("expected hello, got %q", frame.hdr.Type)
175 }
176 h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion - 1})
177
178 select {
179 case err := <-runDone:
180 if err == nil || !strings.Contains(err.Error(), "unsupported host protocol version") {
181 t.Fatalf("Run error = %v", err)
182 }
183 case <-time.After(2 * time.Second):
184 t.Fatal("Run did not reject an unsupported host protocol version")
185 }
186}
187
188// TestOnHelloCanRegisterUsingHostInfo checks that extensions can use
189// host metadata before announcing their initial registrations.
190func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) {
191 h := newHarness("cwd-ext")
192
193 h.ext.OnHello(func(host HostInfo) {
194 h.ext.Command("cwd", "show cwd", func(args string) Response {
195 return Display(host.CWD)
196 })
197 })
198
199 go h.ext.Run()
200
201 f := h.next(t)
202 if f.hdr.Type != "hello" {
203 t.Fatalf("expected hello, got %q", f.hdr.Type)
204 }
205 h.sendToExt(t, extproto.HelloAckFromHost{
206 Type: "hello_ack",
207 ProtocolVersion: extproto.ProtocolVersion,
208 ZutVersion: "0.0.0-test",
209 Provider: "anthropic",
210 Model: "claude-test",
211 CWD: "/tmp/project",
212 })
213
214 var sawCommand bool
215 for {
216 f := h.next(t)
217 if f.hdr.Type == "register_command" {
218 var rc extproto.RegisterCommandFromExt
219 if err := json.Unmarshal(f.raw, &rc); err != nil {
220 t.Fatalf("unmarshal register_command: %v", err)
221 }
222 if rc.Name == "cwd" {
223 sawCommand = true
224 }
225 }
226 if f.hdr.Type == "ready" {
227 break
228 }
229 }
230 if !sawCommand {
231 t.Fatal("OnHello-registered command was not announced before ready")
232 }
233 if got := h.ext.Host().CWD; got != "/tmp/project" {
234 t.Fatalf("Host().CWD = %q, want /tmp/project", got)
235 }
236
237 h.hostW.Close()
238}
239
240// TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a
241// well-formed open_panel frame with the correct PanelSpec fields.
242func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
243 h := newHarness("test-ext")
244 go h.ext.Run()
245 h.handshake(t)
246
247 go h.ext.OpenPanel("my-panel", "My Title", []string{"line a", "line b"}, "esc close")
248
249 f := h.drainUntil(t, "open_panel")
250
251 var op extproto.OpenPanelFromExt
252 if err := json.Unmarshal(f.raw, &op); err != nil {
253 t.Fatalf("unmarshal open_panel: %v", err)
254 }
255 if op.Panel.ID != "my-panel" {
256 t.Errorf("panel id: want %q, got %q", "my-panel", op.Panel.ID)
257 }
258 if op.Panel.Title != "My Title" {
259 t.Errorf("panel title: want %q, got %q", "My Title", op.Panel.Title)
260 }
261 if len(op.Panel.Lines) != 2 || op.Panel.Lines[0] != "line a" || op.Panel.Lines[1] != "line b" {
262 t.Errorf("panel lines: got %v", op.Panel.Lines)
263 }
264 if op.Panel.Footer != "esc close" {
265 t.Errorf("panel footer: want %q, got %q", "esc close", op.Panel.Footer)
266 }
267
268 h.hostW.Close()
269}
270
271func TestPersistentChromeFrames(t *testing.T) {
272 h := newHarness("chrome-ext")
273 h.startRun(t)
274 h.handshake(t)
275
276 h.ext.SetStatus("progress", "2/4 tasks")
277 status := h.drainUntil(t, "status")
278 var sf extproto.StatusFromExt
279 if err := json.Unmarshal(status.raw, &sf); err != nil {
280 t.Fatal(err)
281 }
282 if sf.Key != "progress" || sf.Text != "2/4 tasks" {
283 t.Fatalf("status = %+v", sf)
284 }
285
286 h.ext.SetWidget("plan", WidgetPositionAboveInput, "Plan", []string{"one"})
287 widget := h.drainUntil(t, "widget")
288 var wf extproto.WidgetFromExt
289 if err := json.Unmarshal(widget.raw, &wf); err != nil {
290 t.Fatal(err)
291 }
292 if wf.ID != "plan" || wf.Position != WidgetPositionAboveInput || wf.Title != "Plan" || len(wf.Lines) != 1 || wf.Lines[0] != "one" {
293 t.Fatalf("widget = %+v", wf)
294 }
295
296 h.ext.ClearWidget("plan")
297 clear := h.drainUntil(t, "widget_clear")
298 var cf extproto.ClearWidgetFromExt
299 if err := json.Unmarshal(clear.raw, &cf); err != nil {
300 t.Fatal(err)
301 }
302 if cf.ID != "plan" {
303 t.Fatalf("widget clear = %+v", cf)
304 }
305}
306
307func TestToolResultDetailsEmitsOpaqueMetadata(t *testing.T) {
308 h := newHarness("details-ext")
309 h.ext.Tool("details", "returns metadata", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
310 return ToolResult{Content: []ToolContent{Text("ok")}, Details: JSONDetails(map[string]any{"state": 1})}
311 })
312 h.startRun(t)
313 h.handshake(t)
314 h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "call-1", Name: "details", Args: json.RawMessage(`{}`)})
315 frame := h.drainUntil(t, "tool_result")
316 var result extproto.ToolResultFromExt
317 if err := json.Unmarshal(frame.raw, &result); err != nil {
318 t.Fatal(err)
319 }
320 if string(result.Details) != `{"state":1}` {
321 t.Fatalf("details = %s", result.Details)
322 }
323}
324
325func TestSessionEventCarriesIdentityAndState(t *testing.T) {
326 h := newHarness("session-ext")
327 received := make(chan Event, 1)
328 h.ext.On("session_opened", func(event Event) { received <- event })
329 h.startRun(t)
330 h.handshake(t)
331 h.sendToExt(t, extproto.EventFromHost{
332 Type: "event", Event: "session_opened",
333 Session: &extproto.SessionContext{ID: "branch-1", ParentID: "root-1", Path: "/tmp/session.jsonl", ForkPoint: 4},
334 State: json.RawMessage(`{"version":1}`),
335 })
336 select {
337 case event := <-received:
338 if event.Session == nil || event.Session.ID != "branch-1" || event.Session.ParentID != "root-1" || event.Session.Path != "/tmp/session.jsonl" || event.Session.ForkPoint != 4 || string(event.State) != `{"version":1}` {
339 t.Fatalf("session event = %+v state=%s", event.Session, event.State)
340 }
341 case <-time.After(2 * time.Second):
342 t.Fatal("timeout waiting for session event")
343 }
344}
345
346func TestTurnStartContextIsReturned(t *testing.T) {
347 h := newHarness("context-ext")
348 h.ext.InterceptTurnStart(func(int) TurnStartDecision {
349 return TurnStartDecision{Context: "current phase: parse"}
350 })
351 h.startRun(t)
352 h.handshake(t)
353 h.sendToExt(t, extproto.EventInterceptFromHost{Type: "event_intercept", ID: "intercept-1", Event: "turn_start", Step: 2})
354 frame := h.drainUntil(t, "event_intercept_response")
355 var response extproto.EventInterceptResponseFromExt
356 if err := json.Unmarshal(frame.raw, &response); err != nil {
357 t.Fatal(err)
358 }
359 if response.ID != "intercept-1" || response.Context != "current phase: parse" || response.Block {
360 t.Fatalf("intercept response = %+v", response)
361 }
362}
363
364func TestAlertEmitsStructuredFrame(t *testing.T) {
365 h := newHarness("alert-ext")
366 runDone := make(chan error, 1)
367 go func() { runDone <- h.ext.Run() }()
368 t.Cleanup(func() {
369 _ = h.hostW.Close()
370 select {
371 case err := <-runDone:
372 if err != nil {
373 t.Errorf("extension Run: %v", err)
374 }
375 case <-time.After(2 * time.Second):
376 t.Error("extension Run did not exit after closing host input")
377 }
378 })
379 h.handshake(t)
380
381 go h.ext.Alert(AlertRequest{Kind: AlertKindBell, Reason: "question_ready"})
382 f := h.drainUntil(t, "alert")
383
384 var alert extproto.AlertFromExt
385 if err := json.Unmarshal(f.raw, &alert); err != nil {
386 t.Fatalf("unmarshal alert: %v", err)
387 }
388 if alert.Kind != AlertKindBell || alert.Reason != "question_ready" {
389 t.Fatalf("alert = %+v, want bell/question_ready", alert)
390 }
391}
392
393func TestToolConfirmationRequestedEvent(t *testing.T) {
394 h := newHarness("confirmation-events")
395 received := make(chan Event, 1)
396 h.ext.On("tool_confirmation_requested", func(event Event) {
397 received <- event
398 })
399
400 go h.ext.Run()
401 h.handshake(t)
402 h.sendToExt(t, extproto.EventFromHost{
403 Type: "event",
404 Event: "tool_confirmation_requested",
405 ToolID: "call-1",
406 ToolName: "bash",
407 ToolPreview: "go test ./...",
408 })
409
410 select {
411 case event := <-received:
412 if event.Name != "tool_confirmation_requested" || event.ToolID != "call-1" || event.ToolName != "bash" || event.ToolPreview != "go test ./..." {
413 t.Fatalf("event = %+v", event)
414 }
415 case <-time.After(2 * time.Second):
416 t.Fatal("timeout waiting for confirmation event")
417 }
418 h.hostW.Close()
419}
420
421func TestDeferredToolRegistrationAndActivation(t *testing.T) {
422 h := newHarness("deferred-ext")
423 h.ext.DeferredTool("weather", "weather lookup", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
424 return TextResult("sunny")
425 })
426 h.ext.Tool("search_tools", "find tools", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult {
427 result := TextResult("enabled")
428 result.ActivateTools = []string{"weather"}
429 return result
430 })
431
432 go h.ext.Run()
433 if frame := h.next(t); frame.hdr.Type != "hello" {
434 t.Fatalf("expected hello, got %q", frame.hdr.Type)
435 }
436 h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion})
437
438 deferred := false
439 for {
440 frame := h.next(t)
441 if frame.hdr.Type == "ready" {
442 break
443 }
444 if frame.hdr.Type == "register_tool" {
445 var registration extproto.RegisterToolFromExt
446 if err := json.Unmarshal(frame.raw, ®istration); err != nil {
447 t.Fatal(err)
448 }
449 if registration.Name == "weather" {
450 deferred = registration.Deferred
451 }
452 }
453 }
454 if !deferred {
455 t.Fatal("weather registration was not deferred")
456 }
457
458 h.sendToExt(t, extproto.ToolCallFromHost{Type: "tool_call", ID: "call-1", Name: "search_tools", Args: json.RawMessage(`{}`)})
459 frame := h.drainUntil(t, "tool_result")
460 var result extproto.ToolResultFromExt
461 if err := json.Unmarshal(frame.raw, &result); err != nil {
462 t.Fatal(err)
463 }
464 if len(result.ActivateTools) != 1 || result.ActivateTools[0] != "weather" {
465 t.Fatalf("activate_tools = %v", result.ActivateTools)
466 }
467 h.hostW.Close()
468}
469
470// TestBlockingToolWaitsForPanelKey is the core integration test for
471// the human-in-the-loop pattern: the tool handler opens a panel,
472// blocks on a channel, and only returns a tool_result after a key
473// event arrives.
474func TestBlockingToolWaitsForPanelKey(t *testing.T) {
475 h := newHarness("gate-ext")
476
477 const pid = "gate-panel"
478 const toolCallID = "tc-001"
479
480 approved := make(chan bool, 1)
481
482 h.ext.OnPanelKey(pid, func(key, text string) {
483 switch {
484 case key == "rune" && text == "y":
485 h.ext.ClosePanel(pid)
486 approved <- true
487 case key == "rune" && text == "n", key == "esc":
488 h.ext.ClosePanel(pid)
489 approved <- false
490 }
491 }, func() { approved <- false })
492
493 h.ext.Tool("gate", "needs approval",
494 json.RawMessage(`{"type":"object","properties":{}}`),
495 func(args json.RawMessage) ToolResult {
496 h.ext.OpenPanel(pid, "Approve?",
497 []string{" y approve", " n deny"}, "y/n")
498 if <-approved {
499 return TextResult("approved")
500 }
501 return TextErrorResult("denied")
502 })
503
504 go h.ext.Run()
505 h.handshake(t)
506
507 h.sendToExt(t, extproto.ToolCallFromHost{
508 Type: "tool_call", ID: toolCallID, Name: "gate",
509 Args: json.RawMessage(`{}`),
510 })
511
512 // Tool goroutine must open the panel before it can reply.
513 h.drainUntil(t, "open_panel")
514
515 // Send approval — tool should now unblock and emit tool_result.
516 h.sendToExt(t, extproto.PanelKeyFromHost{
517 Type: "panel_key", PanelID: pid, Key: "rune", Text: "y",
518 })
519
520 f := h.drainUntil(t, "tool_result")
521 var tr extproto.ToolResultFromExt
522 if err := json.Unmarshal(f.raw, &tr); err != nil {
523 t.Fatalf("unmarshal tool_result: %v", err)
524 }
525 if tr.ID != toolCallID {
526 t.Errorf("tool_result id: want %q, got %q", toolCallID, tr.ID)
527 }
528 if tr.IsError {
529 t.Errorf("expected success, got is_error=true")
530 }
531 if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "approved") {
532 t.Errorf("expected 'approved' in content, got %+v", tr.Content)
533 }
534
535 h.hostW.Close()
536}
537
538// TestBlockingToolDenied mirrors TestBlockingToolWaitsForPanelKey but
539// sends "n" so the tool returns an error result.
540func TestBlockingToolDenied(t *testing.T) {
541 h := newHarness("gate-ext-deny")
542
543 const pid = "deny-panel"
544 const toolCallID = "tc-002"
545
546 approved := make(chan bool, 1)
547
548 h.ext.OnPanelKey(pid, func(key, text string) {
549 switch {
550 case key == "rune" && text == "y":
551 h.ext.ClosePanel(pid)
552 approved <- true
553 case key == "rune" && text == "n", key == "esc":
554 h.ext.ClosePanel(pid)
555 approved <- false
556 }
557 }, func() { approved <- false })
558
559 h.ext.Tool("gate2", "needs approval",
560 json.RawMessage(`{"type":"object","properties":{}}`),
561 func(args json.RawMessage) ToolResult {
562 h.ext.OpenPanel(pid, "Approve?", []string{"y/n"}, "")
563 if <-approved {
564 return TextResult("approved")
565 }
566 return TextErrorResult("denied")
567 })
568
569 go h.ext.Run()
570 h.handshake(t)
571
572 h.sendToExt(t, extproto.ToolCallFromHost{
573 Type: "tool_call", ID: toolCallID, Name: "gate2",
574 Args: json.RawMessage(`{}`),
575 })
576
577 h.drainUntil(t, "open_panel")
578
579 h.sendToExt(t, extproto.PanelKeyFromHost{
580 Type: "panel_key", PanelID: pid, Key: "rune", Text: "n",
581 })
582
583 f := h.drainUntil(t, "tool_result")
584 var tr extproto.ToolResultFromExt
585 if err := json.Unmarshal(f.raw, &tr); err != nil {
586 t.Fatalf("unmarshal tool_result: %v", err)
587 }
588 if !tr.IsError {
589 t.Errorf("expected is_error=true on denial")
590 }
591 if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "denied") {
592 t.Errorf("expected 'denied' in content, got %+v", tr.Content)
593 }
594
595 h.hostW.Close()
596}