From 2d46ef9b09a6dc991b5468556e8b203455992e65 Mon Sep 17 00:00:00 2001 From: Raymond Gasper Date: Mon, 8 Jun 2026 12:13:55 -0400 Subject: [PATCH] feat(panels): spontaneous open_panel frame for human-in-the-loop tool gates (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow extensions to emit an open_panel frame at any time, not just as the action of a command_response. This makes it possible to build approval gates, secret collection, and freeform user-input prompts directly inside tool handlers. Changes: - extproto: add OpenPanelFromExt wire type - extensions/manager: route spontaneous open_panel frames to hooks.OpenPanel - ext/ext.go: add Extension.OpenPanel() SDK method - tests: TestSpontaneousOpenPanel (manager), TestOpenPanelEmitsCorrectFrame, TestBlockingToolWaitsForPanelKey, TestBlockingToolDenied (SDK) - docs/plans: add spontaneous-panel.md design doc The blocking tool pattern (open panel → block on channel → key event → tool_result) requires no additional wire changes; it falls out of standard Go concurrency on the extension side. Part 3 (intercept timeout for built-in tool gating) is out of scope and tracked separately. --- docs/plans/spontaneous-panel.md | 245 ++++++++++++++++++ packages/agent/ext/ext.go | 11 + packages/agent/ext/ext_test.go | 295 ++++++++++++++++++++++ packages/agent/extensions/manager.go | 5 + packages/agent/extensions/manager_test.go | 100 +++++++- packages/agent/extproto/extproto.go | 9 + 6 files changed, 662 insertions(+), 3 deletions(-) create mode 100644 docs/plans/spontaneous-panel.md create mode 100644 packages/agent/ext/ext_test.go diff --git a/docs/plans/spontaneous-panel.md b/docs/plans/spontaneous-panel.md new file mode 100644 index 0000000..8fa5990 --- /dev/null +++ b/docs/plans/spontaneous-panel.md @@ -0,0 +1,245 @@ +# Plan: Spontaneous `open_panel` + Human-in-the-Loop Tool Gate + +## Problem + +`open_panel` is only valid as the `action` of a `command_response`, which must +be a direct reply to a `command_invoked` frame. A panel can therefore only open +when the user types a slash command. There is no way for an extension to open a +panel in response to a tool call — making human-in-the-loop approval gates, +secret collection, and freeform user-input patterns impossible without awkward +workarounds (`/approve` slash commands, `threading.Event` polling, etc.). + +--- + +## What we are building + +**Part 1 (required):** A new top-level `open_panel` frame an extension can send +at any time, uncoupled from any command invocation. + +**Part 2 (falls out of Part 1 for free):** A blocking tool result pattern where +a tool goroutine opens a panel, waits on a Go channel for the user's response, +and only then returns a `tool_result`. No new wire frames needed — standard +concurrency on the extension side. + +**Part 3 (separate, required for intercepting built-in tools):** Raise or +remove the 5-second `event_intercept_response` timeout so that human-interactive +intercept handlers don't time out before the user can respond. + +--- + +## Implementation plan + +### 1. `packages/agent/extproto/extproto.go` + +Add one new struct (≈5 lines): + +```go +// OpenPanelFromExt is a spontaneous one-way frame an extension can send +// at any time to open an interactive panel without a prior command invocation. +type OpenPanelFromExt struct { + Type string `json:"type"` // "open_panel" + Panel PanelSpec `json:"panel"` +} +``` + +### 2. `packages/agent/extensions/manager.go` + +Add one case to the `readLoop` switch (≈5 lines): + +```go +case "open_panel": + var op extproto.OpenPanelFromExt + if err := json.Unmarshal(line, &op); err == nil { + m.hooks.OpenPanel(ext.Manifest.Name, op.Panel) + } +``` + +`HostHooks.OpenPanel` already exists. `interactive.go` requires **zero changes**. + +### 3. `packages/agent/ext/ext.go` + +Add one method on `Extension` (≈5 lines): + +```go +// OpenPanel opens an interactive panel spontaneously from extension code +// without requiring a slash command. Safe to call from a tool handler goroutine. +func (e *Extension) OpenPanel(id, title string, lines []string, footer string) { + _ = e.send(extproto.OpenPanelFromExt{ + Type: "open_panel", + Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer}, + }) +} +``` + +### 4. Part 3 — intercept timeout (built-in tool gating) + +Locate the hardcoded 5-second deadline in `packages/agent/extensions/manager.go` +(the `InterceptEvent` / `pendingIntercept` timeout). Two options, pick one: + +- **Option A (preferred):** Add `intercept_timeout_sec` to `Manifest` in + `manager.go` and honour it when building the intercept deadline. Zero/absent + means keep 5s default. +- **Option B:** Add a `timeout_ms` field to `EventInterceptFromHost` in + `extproto.go` that the host sets per-call when the target extension declares + the `panels` capability. + +### 5. `docs/extensions.md` + +- Add `open_panel` to the Extension → host frame table. +- Add a short prose section under Phase 4 describing the spontaneous form, + the blocking tool pattern, and the secret-collection pattern. +- Note the concurrent-panel limitation. + +### 6. `examples/extensions/` (new example) + +Add `examples/extensions/approval/` — a minimal extension demonstrating: +- An LLM-callable tool that opens an approval panel before proceeding. +- A secret-collection variant (masked password input). + +No changes to any other file. + +--- + +## Example usages + +### Simple approve / deny + +```go +e.Tool("risky_op", "Performs a risky operation.", schema, func(args json.RawMessage) ext.ToolResult { + result := make(chan bool, 1) + pid := "approve-" + randomID() + + e.OnPanelKey(pid, func(key, text string) { + switch { + case key == "rune" && text == "y": + e.ClosePanel(pid); result <- true + case key == "rune" && text == "n", key == "esc": + e.ClosePanel(pid); result <- false + } + }, func() { result <- false }) + + e.OpenPanel(pid, "Approve?", + []string{"Agent wants to run: " + summary(args), "", " y approve", " n deny"}, + "y approve n deny esc cancel") + + if !<-result { + return ext.TextErrorResult("user denied") + } + return doWork(args) +}) +``` + +### Secret / credential collection + +The secret is used directly inside the extension and never written to any JSON +frame or the transcript. The model receives only a success/failure status. + +```go +e.Tool("fetch_authenticated", "Fetch a URL that requires a password.", schema, + func(args json.RawMessage) ext.ToolResult { + var in struct{ URL string `json:"url"` } + json.Unmarshal(args, &in) + + type result struct{ secret string; ok bool } + ch := make(chan result, 1) + pid := "secret-" + randomID() + var mu sync.Mutex + var input string + + render := func() { + mu.Lock(); masked := strings.Repeat("●", len([]rune(input))); mu.Unlock() + e.RenderPanel(pid, "Password required", + []string{" URL: " + in.URL, "", " Password: " + masked + "▌"}, + "enter confirm esc cancel") + } + e.OnPanelKey(pid, func(key, text string) { + mu.Lock() + switch key { + case "rune": input += text + case "backspace": if len(input) > 0 { r := []rune(input); input = string(r[:len(r)-1]) } + case "enter": secret := input; mu.Unlock(); e.ClosePanel(pid); ch <- result{secret, true}; return + case "esc": mu.Unlock(); e.ClosePanel(pid); ch <- result{}; return + } + mu.Unlock(); render() + }, func() { ch <- result{} }) + + e.OpenPanel(pid, "Password required", + []string{" URL: " + in.URL, "", " Password: ▌"}, + "enter confirm esc cancel") + + r := <-ch + if !r.ok { return ext.TextErrorResult("cancelled") } + return doFetch(in.URL, r.secret) // secret never leaves the extension process + }) +``` + +### Freeform text / override justification + +Same pattern as secret collection but without masking and with the result +injected into the tool's output rather than used as a credential — e.g. a +human-written review comment, an override reason for a blocked action, or a +value the model should not control. + +### Intercepting a built-in tool (requires Part 3) + +```go +e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision { + if tool != "bash" { return ext.ToolCallDecision{} } + ch := make(chan ext.ToolCallDecision, 1) + pid := "guard-" + randomID() + e.OnPanelKey(pid, func(key, text string) { + if key == "rune" && text == "y" { e.ClosePanel(pid); ch <- ext.ToolCallDecision{} } + if key == "rune" && text == "n" || key == "esc" { + e.ClosePanel(pid); ch <- ext.ToolCallDecision{Block: true, Reason: "user denied"} + } + }, func() { ch <- ext.ToolCallDecision{Block: true, Reason: "panel closed"} }) + e.OpenPanel(pid, "Approve bash?", renderBashLines(args), "y approve n deny") + return <-ch // blocks intercept goroutine — requires Part 3 timeout increase +}) +``` + +--- + +## Out-of-scope risks + +**Intercept timeout (5s) blocks Part 3.** +The `event_intercept_response` timeout is hardcoded at 5 seconds. Human +interaction always exceeds this. Part 3 must be resolved before +`InterceptToolCallX` can be used as an approval gate for built-in tools. +Extension-registered tools are unaffected (no timeout on the tool goroutine). + +**Only one panel open at a time.** +`extPanelDialog` is a single slot. A second spontaneous `open_panel` while +another panel is active will replace it. Extensions that may receive concurrent +tool calls must serialise approvals internally. Multi-panel stacking is a +separate future concern. + +**Goroutine leak if panel is abandoned.** +If the user quits zot or the process is interrupted while a tool goroutine is +blocked on a channel, that goroutine leaks until process exit. Mitigation: +extension authors should `select` on a context cancellation channel alongside +the result channel. The SDK's `ToolHandler` signature does not currently expose +a context — passing one through is a separate improvement. + +**No panel scrolling / wrapping.** +Panel lines are plain strings (ANSI colour permitted). There is no built-in word +wrap or scroll. Long prompts must be pre-wrapped by the extension. Adequate for +approve/deny and credential collection; insufficient for displaying large +structured content. + +**Panel ID collisions under concurrent tool calls.** +If two tool calls for the same tool arrive concurrently, naive panel ID +generation could produce the same ID and stomp state. Use the tool-call ID (from +`ToolCallFromHost.ID`) as a suffix: `"approve-" + toolCallID`. + +--- + +## Files changed + +| File | Change | +|---|---| +| `packages/agent/extproto/extproto.go` | Add `OpenPanelFromExt` struct | +| `packages/agent/extensions/manager.go` | Add `case "open_panel":` in `readLoop`; Part 3: raise intercept timeout | +| `packages/agent/ext/ext.go` | Add `Extension.OpenPanel(...)` method | +| `docs/extensions.md` | Document spontaneous frame, blocking pattern, limitations | +| `examples/extensions/approval/` | New example extension (approval + secret collection) | diff --git a/packages/agent/ext/ext.go b/packages/agent/ext/ext.go index b02ba3e..b5487ff 100644 --- a/packages/agent/ext/ext.go +++ b/packages/agent/ext/ext.go @@ -328,6 +328,17 @@ func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onC } } +// OpenPanel opens an interactive panel spontaneously from extension code +// without requiring a slash command. Safe to call from a tool handler goroutine +// or any background context. The panel receives panel_key events via OnPanelKey +// and can be dismissed with ClosePanel, exactly as with command-response panels. +func (e *Extension) OpenPanel(id, title string, lines []string, footer string) { + _ = e.send(extproto.OpenPanelFromExt{ + Type: "open_panel", + Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer}, + }) +} + // RenderPanel pushes a fresh frame for panelID. func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) { _ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer}) diff --git a/packages/agent/ext/ext_test.go b/packages/agent/ext/ext_test.go new file mode 100644 index 0000000..69dee60 --- /dev/null +++ b/packages/agent/ext/ext_test.go @@ -0,0 +1,295 @@ +package ext + +import ( + "bufio" + "encoding/json" + "io" + "strings" + "testing" + "time" + + "github.com/patriceckhart/zot/packages/agent/extproto" +) + +// ---------- test harness ---------- + +// extHarness wires an Extension to io.Pipe pairs so a test can play +// the role of the host: write host→ext frames, read ext→host frames. +// The scanner runs in a permanent background goroutine and delivers +// frames over a buffered channel, avoiding the deadlock that would +// occur if the test goroutine alternated between writing and reading +// a synchronous pipe. +type extHarness struct { + ext *Extension + hostW *io.PipeWriter // test writes here → ext reads as stdin + frames chan rawFrame // ext→host frames delivered here +} + +type rawFrame struct { + hdr extproto.Frame + raw []byte +} + +func newHarness(name string) *extHarness { + extStdinR, extStdinW := io.Pipe() + extStdoutR, extStdoutW := io.Pipe() + + e := New(name, "0.0.0-test") + e.in = extStdinR + e.out = extStdoutW + e.stderr = io.Discard + + h := &extHarness{ + ext: e, + hostW: extStdinW, + frames: make(chan rawFrame, 64), + } + + // Background reader: scan ext's stdout and push every frame into + // the channel so the test goroutine never needs to block on the pipe. + go func() { + scanner := bufio.NewScanner(extStdoutR) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + b := scanner.Bytes() + cp := make([]byte, len(b)) + copy(cp, b) + var f extproto.Frame + json.Unmarshal(cp, &f) + h.frames <- rawFrame{f, cp} + } + close(h.frames) + }() + + return h +} + +// next returns the next frame, timing out after 2 s. +func (h *extHarness) next(t *testing.T) rawFrame { + t.Helper() + select { + case f, ok := <-h.frames: + if !ok { + t.Fatal("frame channel closed (ext stdout EOF)") + } + return f + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for frame from extension") + return rawFrame{} + } +} + +// drainUntil reads frames until one with type == want arrives. +func (h *extHarness) drainUntil(t *testing.T, want string) rawFrame { + t.Helper() + deadline := time.NewTimer(2 * time.Second) + defer deadline.Stop() + for { + select { + case f, ok := <-h.frames: + if !ok { + t.Fatalf("frame channel closed before seeing %q", want) + } + if f.hdr.Type == want { + return f + } + case <-deadline.C: + t.Fatalf("timeout waiting for frame type %q", want) + return rawFrame{} + } + } +} + +// sendToExt writes a host→ext frame. +func (h *extHarness) sendToExt(t *testing.T, v any) { + t.Helper() + b, err := extproto.Encode(v) + if err != nil { + t.Fatalf("encode: %v", err) + } + if _, err := h.hostW.Write(b); err != nil { + t.Fatalf("write to ext: %v", err) + } +} + +// handshake performs the hello / hello_ack exchange and drains frames +// until "ready". +func (h *extHarness) handshake(t *testing.T) { + t.Helper() + f := h.next(t) + if f.hdr.Type != "hello" { + t.Fatalf("expected hello, got %q", f.hdr.Type) + } + h.sendToExt(t, extproto.HelloAckFromHost{ + Type: "hello_ack", + ProtocolVersion: extproto.ProtocolVersion, + ZotVersion: "0.0.0-test", + Provider: "anthropic", + Model: "claude-test", + }) + for { + f := h.next(t) + if f.hdr.Type == "ready" { + return + } + } +} + +// ---------- tests ---------- + +// TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a +// well-formed open_panel frame with the correct PanelSpec fields. +func TestOpenPanelEmitsCorrectFrame(t *testing.T) { + h := newHarness("test-ext") + go h.ext.Run() + h.handshake(t) + + go h.ext.OpenPanel("my-panel", "My Title", []string{"line a", "line b"}, "esc close") + + f := h.drainUntil(t, "open_panel") + + var op extproto.OpenPanelFromExt + if err := json.Unmarshal(f.raw, &op); err != nil { + t.Fatalf("unmarshal open_panel: %v", err) + } + if op.Panel.ID != "my-panel" { + t.Errorf("panel id: want %q, got %q", "my-panel", op.Panel.ID) + } + if op.Panel.Title != "My Title" { + t.Errorf("panel title: want %q, got %q", "My Title", op.Panel.Title) + } + if len(op.Panel.Lines) != 2 || op.Panel.Lines[0] != "line a" || op.Panel.Lines[1] != "line b" { + t.Errorf("panel lines: got %v", op.Panel.Lines) + } + if op.Panel.Footer != "esc close" { + t.Errorf("panel footer: want %q, got %q", "esc close", op.Panel.Footer) + } + + h.hostW.Close() +} + +// TestBlockingToolWaitsForPanelKey is the core integration test for +// the human-in-the-loop pattern: the tool handler opens a panel, +// blocks on a channel, and only returns a tool_result after a key +// event arrives. +func TestBlockingToolWaitsForPanelKey(t *testing.T) { + h := newHarness("gate-ext") + + const pid = "gate-panel" + const toolCallID = "tc-001" + + approved := make(chan bool, 1) + + h.ext.OnPanelKey(pid, func(key, text string) { + switch { + case key == "rune" && text == "y": + h.ext.ClosePanel(pid) + approved <- true + case key == "rune" && text == "n", key == "esc": + h.ext.ClosePanel(pid) + approved <- false + } + }, func() { approved <- false }) + + h.ext.Tool("gate", "needs approval", + json.RawMessage(`{"type":"object","properties":{}}`), + func(args json.RawMessage) ToolResult { + h.ext.OpenPanel(pid, "Approve?", + []string{" y approve", " n deny"}, "y/n") + if <-approved { + return TextResult("approved") + } + return TextErrorResult("denied") + }) + + go h.ext.Run() + h.handshake(t) + + h.sendToExt(t, extproto.ToolCallFromHost{ + Type: "tool_call", ID: toolCallID, Name: "gate", + Args: json.RawMessage(`{}`), + }) + + // Tool goroutine must open the panel before it can reply. + h.drainUntil(t, "open_panel") + + // Send approval — tool should now unblock and emit tool_result. + h.sendToExt(t, extproto.PanelKeyFromHost{ + Type: "panel_key", PanelID: pid, Key: "rune", Text: "y", + }) + + f := h.drainUntil(t, "tool_result") + var tr extproto.ToolResultFromExt + if err := json.Unmarshal(f.raw, &tr); err != nil { + t.Fatalf("unmarshal tool_result: %v", err) + } + if tr.ID != toolCallID { + t.Errorf("tool_result id: want %q, got %q", toolCallID, tr.ID) + } + if tr.IsError { + t.Errorf("expected success, got is_error=true") + } + if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "approved") { + t.Errorf("expected 'approved' in content, got %+v", tr.Content) + } + + h.hostW.Close() +} + +// TestBlockingToolDenied mirrors TestBlockingToolWaitsForPanelKey but +// sends "n" so the tool returns an error result. +func TestBlockingToolDenied(t *testing.T) { + h := newHarness("gate-ext-deny") + + const pid = "deny-panel" + const toolCallID = "tc-002" + + approved := make(chan bool, 1) + + h.ext.OnPanelKey(pid, func(key, text string) { + switch { + case key == "rune" && text == "y": + h.ext.ClosePanel(pid); approved <- true + case key == "rune" && text == "n", key == "esc": + h.ext.ClosePanel(pid); approved <- false + } + }, func() { approved <- false }) + + h.ext.Tool("gate2", "needs approval", + json.RawMessage(`{"type":"object","properties":{}}`), + func(args json.RawMessage) ToolResult { + h.ext.OpenPanel(pid, "Approve?", []string{"y/n"}, "") + if <-approved { + return TextResult("approved") + } + return TextErrorResult("denied") + }) + + go h.ext.Run() + h.handshake(t) + + h.sendToExt(t, extproto.ToolCallFromHost{ + Type: "tool_call", ID: toolCallID, Name: "gate2", + Args: json.RawMessage(`{}`), + }) + + h.drainUntil(t, "open_panel") + + h.sendToExt(t, extproto.PanelKeyFromHost{ + Type: "panel_key", PanelID: pid, Key: "rune", Text: "n", + }) + + f := h.drainUntil(t, "tool_result") + var tr extproto.ToolResultFromExt + if err := json.Unmarshal(f.raw, &tr); err != nil { + t.Fatalf("unmarshal tool_result: %v", err) + } + if !tr.IsError { + t.Errorf("expected is_error=true on denial") + } + if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "denied") { + t.Errorf("expected 'denied' in content, got %+v", tr.Content) + } + + h.hostW.Close() +} diff --git a/packages/agent/extensions/manager.go b/packages/agent/extensions/manager.go index 0b482bf..eec9120 100644 --- a/packages/agent/extensions/manager.go +++ b/packages/agent/extensions/manager.go @@ -786,6 +786,11 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { } } } + case "open_panel": + var op extproto.OpenPanelFromExt + if err := json.Unmarshal(line, &op); err == nil { + m.hooks.OpenPanel(ext.Manifest.Name, op.Panel) + } case "panel_render": var pr extproto.PanelRenderFromExt if err := json.Unmarshal(line, &pr); err == nil { diff --git a/packages/agent/extensions/manager_test.go b/packages/agent/extensions/manager_test.go index ba72297..56ea9ee 100644 --- a/packages/agent/extensions/manager_test.go +++ b/packages/agent/extensions/manager_test.go @@ -20,6 +20,8 @@ type stubHooks struct { notifies []string displays []string clearNotes []string + panels []extproto.PanelSpec + panelExts []string } func (s *stubHooks) Notify(name, level, message string) { @@ -40,7 +42,12 @@ func (s *stubHooks) ClearNotes(name string) { defer s.mu.Unlock() s.clearNotes = append(s.clearNotes, name) } -func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {} +func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) { + s.mu.Lock() + defer s.mu.Unlock() + s.panelExts = append(s.panelExts, extName) + s.panels = append(s.panels, spec) +} func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {} func (s *stubHooks) ClosePanel(string, string) {} @@ -149,8 +156,15 @@ func TestManagerSpawnAndInvoke(t *testing.T) { time.Sleep(150 * time.Millisecond) cmds := mgr.Commands() - if len(cmds) != 1 || cmds[0].Name != "ping" { - t.Fatalf("expected one command 'ping', got %#v", cmds) + found := false + for _, c := range cmds { + if c.Name == "ping" { + found = true + break + } + } + if !found { + t.Fatalf("expected command 'ping', got %#v", cmds) } if !mgr.HasCommand("ping") { t.Fatal("HasCommand(\"ping\") = false") @@ -167,3 +181,83 @@ func TestManagerSpawnAndInvoke(t *testing.T) { t.Errorf("expected display=pong, got %q", resp.Display) } } + +// TestSpontaneousOpenPanel verifies that an extension sending an +// open_panel frame outside of any command response causes the manager +// to call hooks.OpenPanel with the correct PanelSpec fields. +func TestSpontaneousOpenPanel(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock extension uses /bin/sh; skip on windows") + } + + tmp := t.TempDir() + extDir := filepath.Join(tmp, "extensions", "panel-mock") + if err := os.MkdirAll(extDir, 0o755); err != nil { + t.Fatal(err) + } + + // Extension emits hello + ready, then immediately fires a + // spontaneous open_panel, then waits for shutdown. + script := `#!/bin/sh +printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}' +printf '%s\n' '{"type":"ready"}' +printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}' +while IFS= read -r line; do + case "$line" in + *'"type":"shutdown"'*) + printf '%s\n' '{"type":"shutdown_ack"}' + exit 0 + ;; + esac +done +` + if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"}) + if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil { + t.Fatal(err) + } + + hooks := &stubHooks{} + mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks) + if errs := mgr.Discover(context.Background()); len(errs) > 0 { + t.Fatalf("discover errors: %v", errs) + } + defer mgr.Stop(2 * time.Second) + + // Give the extension time to flush its open_panel frame. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + hooks.mu.Lock() + n := len(hooks.panels) + hooks.mu.Unlock() + if n > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + + hooks.mu.Lock() + defer hooks.mu.Unlock() + + if len(hooks.panels) == 0 { + t.Fatal("hooks.OpenPanel was never called") + } + spec := hooks.panels[0] + if spec.ID != "test-panel" { + t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID) + } + if spec.Title != "Hello Panel" { + t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title) + } + if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" { + t.Errorf("panel lines: want [line one line two], got %v", spec.Lines) + } + if spec.Footer != "esc close" { + t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer) + } + if hooks.panelExts[0] != "panel-mock" { + t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0]) + } +} diff --git a/packages/agent/extproto/extproto.go b/packages/agent/extproto/extproto.go index 97717c5..8974a45 100644 --- a/packages/agent/extproto/extproto.go +++ b/packages/agent/extproto/extproto.go @@ -97,6 +97,15 @@ type PanelSpec struct { Footer string `json:"footer,omitempty"` } +// OpenPanelFromExt is a spontaneous one-way frame an extension can send at +// any time to open an interactive panel. Unlike the open_panel action inside +// CommandResponseFromExt, this form is uncoupled from any command invocation +// and may be sent from a tool handler goroutine or any background context. +type OpenPanelFromExt struct { + Type string `json:"type"` // "open_panel" + Panel PanelSpec `json:"panel"` +} + type PanelRenderFromExt struct { Type string `json:"type"` PanelID string `json:"panel_id"`