v0.27.0 01c3d31

ext_test.go RawHistoryFolder

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, &registration); 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}

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