mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
feat(panels): spontaneous open_panel frame for human-in-the-loop tool gates (#19)
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.
This commit is contained in:
parent
6938d13e90
commit
2d46ef9b09
6 changed files with 662 additions and 3 deletions
245
docs/plans/spontaneous-panel.md
Normal file
245
docs/plans/spontaneous-panel.md
Normal file
|
|
@ -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) |
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
295
packages/agent/ext/ext_test.go
Normal file
295
packages/agent/ext/ext_test.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue