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.
- commit
- 2d46ef9b09a6dc991b5468556e8b203455992e65
- author
- Raymond Gasper · · 2026-06-08 16:13 UTC
- parent
- 6938d13
- signature
- unsigned
6 files · +662 -3
A docs/plans/spontaneous-panel.md +245 -0
| @@ -0,0 +1,245 @@ | |||
| 1 | + | # Plan: Spontaneous `open_panel` + Human-in-the-Loop Tool Gate | |
| 2 | + | ||
| 3 | + | ## Problem | |
| 4 | + | ||
| 5 | + | `open_panel` is only valid as the `action` of a `command_response`, which must | |
| 6 | + | be a direct reply to a `command_invoked` frame. A panel can therefore only open | |
| 7 | + | when the user types a slash command. There is no way for an extension to open a | |
| 8 | + | panel in response to a tool call — making human-in-the-loop approval gates, | |
| 9 | + | secret collection, and freeform user-input patterns impossible without awkward | |
| 10 | + | workarounds (`/approve` slash commands, `threading.Event` polling, etc.). | |
| 11 | + | ||
| 12 | + | --- | |
| 13 | + | ||
| 14 | + | ## What we are building | |
| 15 | + | ||
| 16 | + | **Part 1 (required):** A new top-level `open_panel` frame an extension can send | |
| 17 | + | at any time, uncoupled from any command invocation. | |
| 18 | + | ||
| 19 | + | **Part 2 (falls out of Part 1 for free):** A blocking tool result pattern where | |
| 20 | + | a tool goroutine opens a panel, waits on a Go channel for the user's response, | |
| 21 | + | and only then returns a `tool_result`. No new wire frames needed — standard | |
| 22 | + | concurrency on the extension side. | |
| 23 | + | ||
| 24 | + | **Part 3 (separate, required for intercepting built-in tools):** Raise or | |
| 25 | + | remove the 5-second `event_intercept_response` timeout so that human-interactive | |
| 26 | + | intercept handlers don't time out before the user can respond. | |
| 27 | + | ||
| 28 | + | --- | |
| 29 | + | ||
| 30 | + | ## Implementation plan | |
| 31 | + | ||
| 32 | + | ### 1. `packages/agent/extproto/extproto.go` | |
| 33 | + | ||
| 34 | + | Add one new struct (≈5 lines): | |
| 35 | + | ||
| 36 | + | ```go | |
| 37 | + | // OpenPanelFromExt is a spontaneous one-way frame an extension can send | |
| 38 | + | // at any time to open an interactive panel without a prior command invocation. | |
| 39 | + | type OpenPanelFromExt struct { | |
| 40 | + | Type string `json:"type"` // "open_panel" | |
| 41 | + | Panel PanelSpec `json:"panel"` | |
| 42 | + | } | |
| 43 | + | ``` | |
| 44 | + | ||
| 45 | + | ### 2. `packages/agent/extensions/manager.go` | |
| 46 | + | ||
| 47 | + | Add one case to the `readLoop` switch (≈5 lines): | |
| 48 | + | ||
| 49 | + | ```go | |
| 50 | + | case "open_panel": | |
| 51 | + | var op extproto.OpenPanelFromExt | |
| 52 | + | if err := json.Unmarshal(line, &op); err == nil { | |
| 53 | + | m.hooks.OpenPanel(ext.Manifest.Name, op.Panel) | |
| 54 | + | } | |
| 55 | + | ``` | |
| 56 | + | ||
| 57 | + | `HostHooks.OpenPanel` already exists. `interactive.go` requires **zero changes**. | |
| 58 | + | ||
| 59 | + | ### 3. `packages/agent/ext/ext.go` | |
| 60 | + | ||
| 61 | + | Add one method on `Extension` (≈5 lines): | |
| 62 | + | ||
| 63 | + | ```go | |
| 64 | + | // OpenPanel opens an interactive panel spontaneously from extension code | |
| 65 | + | // without requiring a slash command. Safe to call from a tool handler goroutine. | |
| 66 | + | func (e *Extension) OpenPanel(id, title string, lines []string, footer string) { | |
| 67 | + | _ = e.send(extproto.OpenPanelFromExt{ | |
| 68 | + | Type: "open_panel", | |
| 69 | + | Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer}, | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | ``` | |
| 73 | + | ||
| 74 | + | ### 4. Part 3 — intercept timeout (built-in tool gating) | |
| 75 | + | ||
| 76 | + | Locate the hardcoded 5-second deadline in `packages/agent/extensions/manager.go` | |
| 77 | + | (the `InterceptEvent` / `pendingIntercept` timeout). Two options, pick one: | |
| 78 | + | ||
| 79 | + | - **Option A (preferred):** Add `intercept_timeout_sec` to `Manifest` in | |
| 80 | + | `manager.go` and honour it when building the intercept deadline. Zero/absent | |
| 81 | + | means keep 5s default. | |
| 82 | + | - **Option B:** Add a `timeout_ms` field to `EventInterceptFromHost` in | |
| 83 | + | `extproto.go` that the host sets per-call when the target extension declares | |
| 84 | + | the `panels` capability. | |
| 85 | + | ||
| 86 | + | ### 5. `docs/extensions.md` | |
| 87 | + | ||
| 88 | + | - Add `open_panel` to the Extension → host frame table. | |
| 89 | + | - Add a short prose section under Phase 4 describing the spontaneous form, | |
| 90 | + | the blocking tool pattern, and the secret-collection pattern. | |
| 91 | + | - Note the concurrent-panel limitation. | |
| 92 | + | ||
| 93 | + | ### 6. `examples/extensions/` (new example) | |
| 94 | + | ||
| 95 | + | Add `examples/extensions/approval/` — a minimal extension demonstrating: | |
| 96 | + | - An LLM-callable tool that opens an approval panel before proceeding. | |
| 97 | + | - A secret-collection variant (masked password input). | |
| 98 | + | ||
| 99 | + | No changes to any other file. | |
| 100 | + | ||
| 101 | + | --- | |
| 102 | + | ||
| 103 | + | ## Example usages | |
| 104 | + | ||
| 105 | + | ### Simple approve / deny | |
| 106 | + | ||
| 107 | + | ```go | |
| 108 | + | e.Tool("risky_op", "Performs a risky operation.", schema, func(args json.RawMessage) ext.ToolResult { | |
| 109 | + | result := make(chan bool, 1) | |
| 110 | + | pid := "approve-" + randomID() | |
| 111 | + | ||
| 112 | + | e.OnPanelKey(pid, func(key, text string) { | |
| 113 | + | switch { | |
| 114 | + | case key == "rune" && text == "y": | |
| 115 | + | e.ClosePanel(pid); result <- true | |
| 116 | + | case key == "rune" && text == "n", key == "esc": | |
| 117 | + | e.ClosePanel(pid); result <- false | |
| 118 | + | } | |
| 119 | + | }, func() { result <- false }) | |
| 120 | + | ||
| 121 | + | e.OpenPanel(pid, "Approve?", | |
| 122 | + | []string{"Agent wants to run: " + summary(args), "", " y approve", " n deny"}, | |
| 123 | + | "y approve n deny esc cancel") | |
| 124 | + | ||
| 125 | + | if !<-result { | |
| 126 | + | return ext.TextErrorResult("user denied") | |
| 127 | + | } | |
| 128 | + | return doWork(args) | |
| 129 | + | }) | |
| 130 | + | ``` | |
| 131 | + | ||
| 132 | + | ### Secret / credential collection | |
| 133 | + | ||
| 134 | + | The secret is used directly inside the extension and never written to any JSON | |
| 135 | + | frame or the transcript. The model receives only a success/failure status. | |
| 136 | + | ||
| 137 | + | ```go | |
| 138 | + | e.Tool("fetch_authenticated", "Fetch a URL that requires a password.", schema, | |
| 139 | + | func(args json.RawMessage) ext.ToolResult { | |
| 140 | + | var in struct{ URL string `json:"url"` } | |
| 141 | + | json.Unmarshal(args, &in) | |
| 142 | + | ||
| 143 | + | type result struct{ secret string; ok bool } | |
| 144 | + | ch := make(chan result, 1) | |
| 145 | + | pid := "secret-" + randomID() | |
| 146 | + | var mu sync.Mutex | |
| 147 | + | var input string | |
| 148 | + | ||
| 149 | + | render := func() { | |
| 150 | + | mu.Lock(); masked := strings.Repeat("●", len([]rune(input))); mu.Unlock() | |
| 151 | + | e.RenderPanel(pid, "Password required", | |
| 152 | + | []string{" URL: " + in.URL, "", " Password: " + masked + "▌"}, | |
| 153 | + | "enter confirm esc cancel") | |
| 154 | + | } | |
| 155 | + | e.OnPanelKey(pid, func(key, text string) { | |
| 156 | + | mu.Lock() | |
| 157 | + | switch key { | |
| 158 | + | case "rune": input += text | |
| 159 | + | case "backspace": if len(input) > 0 { r := []rune(input); input = string(r[:len(r)-1]) } | |
| 160 | + | case "enter": secret := input; mu.Unlock(); e.ClosePanel(pid); ch <- result{secret, true}; return | |
| 161 | + | case "esc": mu.Unlock(); e.ClosePanel(pid); ch <- result{}; return | |
| 162 | + | } | |
| 163 | + | mu.Unlock(); render() | |
| 164 | + | }, func() { ch <- result{} }) | |
| 165 | + | ||
| 166 | + | e.OpenPanel(pid, "Password required", | |
| 167 | + | []string{" URL: " + in.URL, "", " Password: ▌"}, | |
| 168 | + | "enter confirm esc cancel") | |
| 169 | + | ||
| 170 | + | r := <-ch | |
| 171 | + | if !r.ok { return ext.TextErrorResult("cancelled") } | |
| 172 | + | return doFetch(in.URL, r.secret) // secret never leaves the extension process | |
| 173 | + | }) | |
| 174 | + | ``` | |
| 175 | + | ||
| 176 | + | ### Freeform text / override justification | |
| 177 | + | ||
| 178 | + | Same pattern as secret collection but without masking and with the result | |
| 179 | + | injected into the tool's output rather than used as a credential — e.g. a | |
| 180 | + | human-written review comment, an override reason for a blocked action, or a | |
| 181 | + | value the model should not control. | |
| 182 | + | ||
| 183 | + | ### Intercepting a built-in tool (requires Part 3) | |
| 184 | + | ||
| 185 | + | ```go | |
| 186 | + | e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision { | |
| 187 | + | if tool != "bash" { return ext.ToolCallDecision{} } | |
| 188 | + | ch := make(chan ext.ToolCallDecision, 1) | |
| 189 | + | pid := "guard-" + randomID() | |
| 190 | + | e.OnPanelKey(pid, func(key, text string) { | |
| 191 | + | if key == "rune" && text == "y" { e.ClosePanel(pid); ch <- ext.ToolCallDecision{} } | |
| 192 | + | if key == "rune" && text == "n" || key == "esc" { | |
| 193 | + | e.ClosePanel(pid); ch <- ext.ToolCallDecision{Block: true, Reason: "user denied"} | |
| 194 | + | } | |
| 195 | + | }, func() { ch <- ext.ToolCallDecision{Block: true, Reason: "panel closed"} }) | |
| 196 | + | e.OpenPanel(pid, "Approve bash?", renderBashLines(args), "y approve n deny") | |
| 197 | + | return <-ch // blocks intercept goroutine — requires Part 3 timeout increase | |
| 198 | + | }) | |
| 199 | + | ``` | |
| 200 | + | ||
| 201 | + | --- | |
| 202 | + | ||
| 203 | + | ## Out-of-scope risks | |
| 204 | + | ||
| 205 | + | **Intercept timeout (5s) blocks Part 3.** | |
| 206 | + | The `event_intercept_response` timeout is hardcoded at 5 seconds. Human | |
| 207 | + | interaction always exceeds this. Part 3 must be resolved before | |
| 208 | + | `InterceptToolCallX` can be used as an approval gate for built-in tools. | |
| 209 | + | Extension-registered tools are unaffected (no timeout on the tool goroutine). | |
| 210 | + | ||
| 211 | + | **Only one panel open at a time.** | |
| 212 | + | `extPanelDialog` is a single slot. A second spontaneous `open_panel` while | |
| 213 | + | another panel is active will replace it. Extensions that may receive concurrent | |
| 214 | + | tool calls must serialise approvals internally. Multi-panel stacking is a | |
| 215 | + | separate future concern. | |
| 216 | + | ||
| 217 | + | **Goroutine leak if panel is abandoned.** | |
| 218 | + | If the user quits zot or the process is interrupted while a tool goroutine is | |
| 219 | + | blocked on a channel, that goroutine leaks until process exit. Mitigation: | |
| 220 | + | extension authors should `select` on a context cancellation channel alongside | |
| 221 | + | the result channel. The SDK's `ToolHandler` signature does not currently expose | |
| 222 | + | a context — passing one through is a separate improvement. | |
| 223 | + | ||
| 224 | + | **No panel scrolling / wrapping.** | |
| 225 | + | Panel lines are plain strings (ANSI colour permitted). There is no built-in word | |
| 226 | + | wrap or scroll. Long prompts must be pre-wrapped by the extension. Adequate for | |
| 227 | + | approve/deny and credential collection; insufficient for displaying large | |
| 228 | + | structured content. | |
| 229 | + | ||
| 230 | + | **Panel ID collisions under concurrent tool calls.** | |
| 231 | + | If two tool calls for the same tool arrive concurrently, naive panel ID | |
| 232 | + | generation could produce the same ID and stomp state. Use the tool-call ID (from | |
| 233 | + | `ToolCallFromHost.ID`) as a suffix: `"approve-" + toolCallID`. | |
| 234 | + | ||
| 235 | + | --- | |
| 236 | + | ||
| 237 | + | ## Files changed | |
| 238 | + | ||
| 239 | + | | File | Change | | |
| 240 | + | |---|---| | |
| 241 | + | | `packages/agent/extproto/extproto.go` | Add `OpenPanelFromExt` struct | | |
| 242 | + | | `packages/agent/extensions/manager.go` | Add `case "open_panel":` in `readLoop`; Part 3: raise intercept timeout | | |
| 243 | + | | `packages/agent/ext/ext.go` | Add `Extension.OpenPanel(...)` method | | |
| 244 | + | | `docs/extensions.md` | Document spontaneous frame, blocking pattern, limitations | | |
| 245 | + | | `examples/extensions/approval/` | New example extension (approval + secret collection) | | |
| @@ -0,0 +1,245 @@ | |||||
| 1 | + | # Plan: Spontaneous `open_panel` + Human-in-the-Loop Tool Gate | |||
| 2 | + | ||||
| 3 | + | ## Problem | |||
| 4 | + | ||||
| 5 | + | `open_panel` is only valid as the `action` of a `command_response`, which must | |||
| 6 | + | be a direct reply to a `command_invoked` frame. A panel can therefore only open | |||
| 7 | + | when the user types a slash command. There is no way for an extension to open a | |||
| 8 | + | panel in response to a tool call — making human-in-the-loop approval gates, | |||
| 9 | + | secret collection, and freeform user-input patterns impossible without awkward | |||
| 10 | + | workarounds (`/approve` slash commands, `threading.Event` polling, etc.). | |||
| 11 | + | ||||
| 12 | + | --- | |||
| 13 | + | ||||
| 14 | + | ## What we are building | |||
| 15 | + | ||||
| 16 | + | **Part 1 (required):** A new top-level `open_panel` frame an extension can send | |||
| 17 | + | at any time, uncoupled from any command invocation. | |||
| 18 | + | ||||
| 19 | + | **Part 2 (falls out of Part 1 for free):** A blocking tool result pattern where | |||
| 20 | + | a tool goroutine opens a panel, waits on a Go channel for the user's response, | |||
| 21 | + | and only then returns a `tool_result`. No new wire frames needed — standard | |||
| 22 | + | concurrency on the extension side. | |||
| 23 | + | ||||
| 24 | + | **Part 3 (separate, required for intercepting built-in tools):** Raise or | |||
| 25 | + | remove the 5-second `event_intercept_response` timeout so that human-interactive | |||
| 26 | + | intercept handlers don't time out before the user can respond. | |||
| 27 | + | ||||
| 28 | + | --- | |||
| 29 | + | ||||
| 30 | + | ## Implementation plan | |||
| 31 | + | ||||
| 32 | + | ### 1. `packages/agent/extproto/extproto.go` | |||
| 33 | + | ||||
| 34 | + | Add one new struct (≈5 lines): | |||
| 35 | + | ||||
| 36 | + | ```go | |||
| 37 | + | // OpenPanelFromExt is a spontaneous one-way frame an extension can send | |||
| 38 | + | // at any time to open an interactive panel without a prior command invocation. | |||
| 39 | + | type OpenPanelFromExt struct { | |||
| 40 | + | Type string `json:"type"` // "open_panel" | |||
| 41 | + | Panel PanelSpec `json:"panel"` | |||
| 42 | + | } | |||
| 43 | + | ``` | |||
| 44 | + | ||||
| 45 | + | ### 2. `packages/agent/extensions/manager.go` | |||
| 46 | + | ||||
| 47 | + | Add one case to the `readLoop` switch (≈5 lines): | |||
| 48 | + | ||||
| 49 | + | ```go | |||
| 50 | + | case "open_panel": | |||
| 51 | + | var op extproto.OpenPanelFromExt | |||
| 52 | + | if err := json.Unmarshal(line, &op); err == nil { | |||
| 53 | + | m.hooks.OpenPanel(ext.Manifest.Name, op.Panel) | |||
| 54 | + | } | |||
| 55 | + | ``` | |||
| 56 | + | ||||
| 57 | + | `HostHooks.OpenPanel` already exists. `interactive.go` requires **zero changes**. | |||
| 58 | + | ||||
| 59 | + | ### 3. `packages/agent/ext/ext.go` | |||
| 60 | + | ||||
| 61 | + | Add one method on `Extension` (≈5 lines): | |||
| 62 | + | ||||
| 63 | + | ```go | |||
| 64 | + | // OpenPanel opens an interactive panel spontaneously from extension code | |||
| 65 | + | // without requiring a slash command. Safe to call from a tool handler goroutine. | |||
| 66 | + | func (e *Extension) OpenPanel(id, title string, lines []string, footer string) { | |||
| 67 | + | _ = e.send(extproto.OpenPanelFromExt{ | |||
| 68 | + | Type: "open_panel", | |||
| 69 | + | Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer}, | |||
| 70 | + | }) | |||
| 71 | + | } | |||
| 72 | + | ``` | |||
| 73 | + | ||||
| 74 | + | ### 4. Part 3 — intercept timeout (built-in tool gating) | |||
| 75 | + | ||||
| 76 | + | Locate the hardcoded 5-second deadline in `packages/agent/extensions/manager.go` | |||
| 77 | + | (the `InterceptEvent` / `pendingIntercept` timeout). Two options, pick one: | |||
| 78 | + | ||||
| 79 | + | - **Option A (preferred):** Add `intercept_timeout_sec` to `Manifest` in | |||
| 80 | + | `manager.go` and honour it when building the intercept deadline. Zero/absent | |||
| 81 | + | means keep 5s default. | |||
| 82 | + | - **Option B:** Add a `timeout_ms` field to `EventInterceptFromHost` in | |||
| 83 | + | `extproto.go` that the host sets per-call when the target extension declares | |||
| 84 | + | the `panels` capability. | |||
| 85 | + | ||||
| 86 | + | ### 5. `docs/extensions.md` | |||
| 87 | + | ||||
| 88 | + | - Add `open_panel` to the Extension → host frame table. | |||
| 89 | + | - Add a short prose section under Phase 4 describing the spontaneous form, | |||
| 90 | + | the blocking tool pattern, and the secret-collection pattern. | |||
| 91 | + | - Note the concurrent-panel limitation. | |||
| 92 | + | ||||
| 93 | + | ### 6. `examples/extensions/` (new example) | |||
| 94 | + | ||||
| 95 | + | Add `examples/extensions/approval/` — a minimal extension demonstrating: | |||
| 96 | + | - An LLM-callable tool that opens an approval panel before proceeding. | |||
| 97 | + | - A secret-collection variant (masked password input). | |||
| 98 | + | ||||
| 99 | + | No changes to any other file. | |||
| 100 | + | ||||
| 101 | + | --- | |||
| 102 | + | ||||
| 103 | + | ## Example usages | |||
| 104 | + | ||||
| 105 | + | ### Simple approve / deny | |||
| 106 | + | ||||
| 107 | + | ```go | |||
| 108 | + | e.Tool("risky_op", "Performs a risky operation.", schema, func(args json.RawMessage) ext.ToolResult { | |||
| 109 | + | result := make(chan bool, 1) | |||
| 110 | + | pid := "approve-" + randomID() | |||
| 111 | + | ||||
| 112 | + | e.OnPanelKey(pid, func(key, text string) { | |||
| 113 | + | switch { | |||
| 114 | + | case key == "rune" && text == "y": | |||
| 115 | + | e.ClosePanel(pid); result <- true | |||
| 116 | + | case key == "rune" && text == "n", key == "esc": | |||
| 117 | + | e.ClosePanel(pid); result <- false | |||
| 118 | + | } | |||
| 119 | + | }, func() { result <- false }) | |||
| 120 | + | ||||
| 121 | + | e.OpenPanel(pid, "Approve?", | |||
| 122 | + | []string{"Agent wants to run: " + summary(args), "", " y approve", " n deny"}, | |||
| 123 | + | "y approve n deny esc cancel") | |||
| 124 | + | ||||
| 125 | + | if !<-result { | |||
| 126 | + | return ext.TextErrorResult("user denied") | |||
| 127 | + | } | |||
| 128 | + | return doWork(args) | |||
| 129 | + | }) | |||
| 130 | + | ``` | |||
| 131 | + | ||||
| 132 | + | ### Secret / credential collection | |||
| 133 | + | ||||
| 134 | + | The secret is used directly inside the extension and never written to any JSON | |||
| 135 | + | frame or the transcript. The model receives only a success/failure status. | |||
| 136 | + | ||||
| 137 | + | ```go | |||
| 138 | + | e.Tool("fetch_authenticated", "Fetch a URL that requires a password.", schema, | |||
| 139 | + | func(args json.RawMessage) ext.ToolResult { | |||
| 140 | + | var in struct{ URL string `json:"url"` } | |||
| 141 | + | json.Unmarshal(args, &in) | |||
| 142 | + | ||||
| 143 | + | type result struct{ secret string; ok bool } | |||
| 144 | + | ch := make(chan result, 1) | |||
| 145 | + | pid := "secret-" + randomID() | |||
| 146 | + | var mu sync.Mutex | |||
| 147 | + | var input string | |||
| 148 | + | ||||
| 149 | + | render := func() { | |||
| 150 | + | mu.Lock(); masked := strings.Repeat("●", len([]rune(input))); mu.Unlock() | |||
| 151 | + | e.RenderPanel(pid, "Password required", | |||
| 152 | + | []string{" URL: " + in.URL, "", " Password: " + masked + "▌"}, | |||
| 153 | + | "enter confirm esc cancel") | |||
| 154 | + | } | |||
| 155 | + | e.OnPanelKey(pid, func(key, text string) { | |||
| 156 | + | mu.Lock() | |||
| 157 | + | switch key { | |||
| 158 | + | case "rune": input += text | |||
| 159 | + | case "backspace": if len(input) > 0 { r := []rune(input); input = string(r[:len(r)-1]) } | |||
| 160 | + | case "enter": secret := input; mu.Unlock(); e.ClosePanel(pid); ch <- result{secret, true}; return | |||
| 161 | + | case "esc": mu.Unlock(); e.ClosePanel(pid); ch <- result{}; return | |||
| 162 | + | } | |||
| 163 | + | mu.Unlock(); render() | |||
| 164 | + | }, func() { ch <- result{} }) | |||
| 165 | + | ||||
| 166 | + | e.OpenPanel(pid, "Password required", | |||
| 167 | + | []string{" URL: " + in.URL, "", " Password: ▌"}, | |||
| 168 | + | "enter confirm esc cancel") | |||
| 169 | + | ||||
| 170 | + | r := <-ch | |||
| 171 | + | if !r.ok { return ext.TextErrorResult("cancelled") } | |||
| 172 | + | return doFetch(in.URL, r.secret) // secret never leaves the extension process | |||
| 173 | + | }) | |||
| 174 | + | ``` | |||
| 175 | + | ||||
| 176 | + | ### Freeform text / override justification | |||
| 177 | + | ||||
| 178 | + | Same pattern as secret collection but without masking and with the result | |||
| 179 | + | injected into the tool's output rather than used as a credential — e.g. a | |||
| 180 | + | human-written review comment, an override reason for a blocked action, or a | |||
| 181 | + | value the model should not control. | |||
| 182 | + | ||||
| 183 | + | ### Intercepting a built-in tool (requires Part 3) | |||
| 184 | + | ||||
| 185 | + | ```go | |||
| 186 | + | e.InterceptToolCallX(func(tool string, args json.RawMessage) ext.ToolCallDecision { | |||
| 187 | + | if tool != "bash" { return ext.ToolCallDecision{} } | |||
| 188 | + | ch := make(chan ext.ToolCallDecision, 1) | |||
| 189 | + | pid := "guard-" + randomID() | |||
| 190 | + | e.OnPanelKey(pid, func(key, text string) { | |||
| 191 | + | if key == "rune" && text == "y" { e.ClosePanel(pid); ch <- ext.ToolCallDecision{} } | |||
| 192 | + | if key == "rune" && text == "n" || key == "esc" { | |||
| 193 | + | e.ClosePanel(pid); ch <- ext.ToolCallDecision{Block: true, Reason: "user denied"} | |||
| 194 | + | } | |||
| 195 | + | }, func() { ch <- ext.ToolCallDecision{Block: true, Reason: "panel closed"} }) | |||
| 196 | + | e.OpenPanel(pid, "Approve bash?", renderBashLines(args), "y approve n deny") | |||
| 197 | + | return <-ch // blocks intercept goroutine — requires Part 3 timeout increase | |||
| 198 | + | }) | |||
| 199 | + | ``` | |||
| 200 | + | ||||
| 201 | + | --- | |||
| 202 | + | ||||
| 203 | + | ## Out-of-scope risks | |||
| 204 | + | ||||
| 205 | + | **Intercept timeout (5s) blocks Part 3.** | |||
| 206 | + | The `event_intercept_response` timeout is hardcoded at 5 seconds. Human | |||
| 207 | + | interaction always exceeds this. Part 3 must be resolved before | |||
| 208 | + | `InterceptToolCallX` can be used as an approval gate for built-in tools. | |||
| 209 | + | Extension-registered tools are unaffected (no timeout on the tool goroutine). | |||
| 210 | + | ||||
| 211 | + | **Only one panel open at a time.** | |||
| 212 | + | `extPanelDialog` is a single slot. A second spontaneous `open_panel` while | |||
| 213 | + | another panel is active will replace it. Extensions that may receive concurrent | |||
| 214 | + | tool calls must serialise approvals internally. Multi-panel stacking is a | |||
| 215 | + | separate future concern. | |||
| 216 | + | ||||
| 217 | + | **Goroutine leak if panel is abandoned.** | |||
| 218 | + | If the user quits zot or the process is interrupted while a tool goroutine is | |||
| 219 | + | blocked on a channel, that goroutine leaks until process exit. Mitigation: | |||
| 220 | + | extension authors should `select` on a context cancellation channel alongside | |||
| 221 | + | the result channel. The SDK's `ToolHandler` signature does not currently expose | |||
| 222 | + | a context — passing one through is a separate improvement. | |||
| 223 | + | ||||
| 224 | + | **No panel scrolling / wrapping.** | |||
| 225 | + | Panel lines are plain strings (ANSI colour permitted). There is no built-in word | |||
| 226 | + | wrap or scroll. Long prompts must be pre-wrapped by the extension. Adequate for | |||
| 227 | + | approve/deny and credential collection; insufficient for displaying large | |||
| 228 | + | structured content. | |||
| 229 | + | ||||
| 230 | + | **Panel ID collisions under concurrent tool calls.** | |||
| 231 | + | If two tool calls for the same tool arrive concurrently, naive panel ID | |||
| 232 | + | generation could produce the same ID and stomp state. Use the tool-call ID (from | |||
| 233 | + | `ToolCallFromHost.ID`) as a suffix: `"approve-" + toolCallID`. | |||
| 234 | + | ||||
| 235 | + | --- | |||
| 236 | + | ||||
| 237 | + | ## Files changed | |||
| 238 | + | ||||
| 239 | + | | File | Change | | |||
| 240 | + | |---|---| | |||
| 241 | + | | `packages/agent/extproto/extproto.go` | Add `OpenPanelFromExt` struct | | |||
| 242 | + | | `packages/agent/extensions/manager.go` | Add `case "open_panel":` in `readLoop`; Part 3: raise intercept timeout | | |||
| 243 | + | | `packages/agent/ext/ext.go` | Add `Extension.OpenPanel(...)` method | | |||
| 244 | + | | `docs/extensions.md` | Document spontaneous frame, blocking pattern, limitations | | |||
| 245 | + | | `examples/extensions/approval/` | New example extension (approval + secret collection) | | |||
M packages/agent/ext/ext.go +11 -0
| @@ -328,6 +328,17 @@ func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onC | |||
| 328 | 328 | } | |
| 329 | 329 | } | |
| 330 | 330 | ||
| 331 | + | // OpenPanel opens an interactive panel spontaneously from extension code | |
| 332 | + | // without requiring a slash command. Safe to call from a tool handler goroutine | |
| 333 | + | // or any background context. The panel receives panel_key events via OnPanelKey | |
| 334 | + | // and can be dismissed with ClosePanel, exactly as with command-response panels. | |
| 335 | + | func (e *Extension) OpenPanel(id, title string, lines []string, footer string) { | |
| 336 | + | _ = e.send(extproto.OpenPanelFromExt{ | |
| 337 | + | Type: "open_panel", | |
| 338 | + | Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer}, | |
| 339 | + | }) | |
| 340 | + | } | |
| 341 | + | ||
| 331 | 342 | // RenderPanel pushes a fresh frame for panelID. | |
| 332 | 343 | func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) { | |
| 333 | 344 | _ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer}) | |
| @@ -328,6 +328,17 @@ func (e *Extension) OnPanelKey(panelID string, onKey func(key, text string), onC | |||||
| 328 | } | 328 | } | ||
| 329 | } | 329 | } | ||
| 330 | 330 | ||||
| 331 | + | // OpenPanel opens an interactive panel spontaneously from extension code | |||
| 332 | + | // without requiring a slash command. Safe to call from a tool handler goroutine | |||
| 333 | + | // or any background context. The panel receives panel_key events via OnPanelKey | |||
| 334 | + | // and can be dismissed with ClosePanel, exactly as with command-response panels. | |||
| 335 | + | func (e *Extension) OpenPanel(id, title string, lines []string, footer string) { | |||
| 336 | + | _ = e.send(extproto.OpenPanelFromExt{ | |||
| 337 | + | Type: "open_panel", | |||
| 338 | + | Panel: extproto.PanelSpec{ID: id, Title: title, Lines: lines, Footer: footer}, | |||
| 339 | + | }) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 331 | // RenderPanel pushes a fresh frame for panelID. | 342 | // RenderPanel pushes a fresh frame for panelID. | ||
| 332 | func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) { | 343 | func (e *Extension) RenderPanel(panelID, title string, lines []string, footer string) { | ||
| 333 | _ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer}) | 344 | _ = e.send(extproto.PanelRenderFromExt{Type: "panel_render", PanelID: panelID, Title: title, Lines: lines, Footer: footer}) | ||
A packages/agent/ext/ext_test.go +295 -0
| @@ -0,0 +1,295 @@ | |||
| 1 | + | package ext | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "encoding/json" | |
| 6 | + | "io" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ---------- test harness ---------- | |
| 15 | + | ||
| 16 | + | // extHarness wires an Extension to io.Pipe pairs so a test can play | |
| 17 | + | // the role of the host: write host→ext frames, read ext→host frames. | |
| 18 | + | // The scanner runs in a permanent background goroutine and delivers | |
| 19 | + | // frames over a buffered channel, avoiding the deadlock that would | |
| 20 | + | // occur if the test goroutine alternated between writing and reading | |
| 21 | + | // a synchronous pipe. | |
| 22 | + | type extHarness struct { | |
| 23 | + | ext *Extension | |
| 24 | + | hostW *io.PipeWriter // test writes here → ext reads as stdin | |
| 25 | + | frames chan rawFrame // ext→host frames delivered here | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type rawFrame struct { | |
| 29 | + | hdr extproto.Frame | |
| 30 | + | raw []byte | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func newHarness(name string) *extHarness { | |
| 34 | + | extStdinR, extStdinW := io.Pipe() | |
| 35 | + | extStdoutR, extStdoutW := io.Pipe() | |
| 36 | + | ||
| 37 | + | e := New(name, "0.0.0-test") | |
| 38 | + | e.in = extStdinR | |
| 39 | + | e.out = extStdoutW | |
| 40 | + | e.stderr = io.Discard | |
| 41 | + | ||
| 42 | + | h := &extHarness{ | |
| 43 | + | ext: e, | |
| 44 | + | hostW: extStdinW, | |
| 45 | + | frames: make(chan rawFrame, 64), | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Background reader: scan ext's stdout and push every frame into | |
| 49 | + | // the channel so the test goroutine never needs to block on the pipe. | |
| 50 | + | go func() { | |
| 51 | + | scanner := bufio.NewScanner(extStdoutR) | |
| 52 | + | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | |
| 53 | + | for scanner.Scan() { | |
| 54 | + | b := scanner.Bytes() | |
| 55 | + | cp := make([]byte, len(b)) | |
| 56 | + | copy(cp, b) | |
| 57 | + | var f extproto.Frame | |
| 58 | + | json.Unmarshal(cp, &f) | |
| 59 | + | h.frames <- rawFrame{f, cp} | |
| 60 | + | } | |
| 61 | + | close(h.frames) | |
| 62 | + | }() | |
| 63 | + | ||
| 64 | + | return h | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // next returns the next frame, timing out after 2 s. | |
| 68 | + | func (h *extHarness) next(t *testing.T) rawFrame { | |
| 69 | + | t.Helper() | |
| 70 | + | select { | |
| 71 | + | case f, ok := <-h.frames: | |
| 72 | + | if !ok { | |
| 73 | + | t.Fatal("frame channel closed (ext stdout EOF)") | |
| 74 | + | } | |
| 75 | + | return f | |
| 76 | + | case <-time.After(2 * time.Second): | |
| 77 | + | t.Fatal("timeout waiting for frame from extension") | |
| 78 | + | return rawFrame{} | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // drainUntil reads frames until one with type == want arrives. | |
| 83 | + | func (h *extHarness) drainUntil(t *testing.T, want string) rawFrame { | |
| 84 | + | t.Helper() | |
| 85 | + | deadline := time.NewTimer(2 * time.Second) | |
| 86 | + | defer deadline.Stop() | |
| 87 | + | for { | |
| 88 | + | select { | |
| 89 | + | case f, ok := <-h.frames: | |
| 90 | + | if !ok { | |
| 91 | + | t.Fatalf("frame channel closed before seeing %q", want) | |
| 92 | + | } | |
| 93 | + | if f.hdr.Type == want { | |
| 94 | + | return f | |
| 95 | + | } | |
| 96 | + | case <-deadline.C: | |
| 97 | + | t.Fatalf("timeout waiting for frame type %q", want) | |
| 98 | + | return rawFrame{} | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // sendToExt writes a host→ext frame. | |
| 104 | + | func (h *extHarness) sendToExt(t *testing.T, v any) { | |
| 105 | + | t.Helper() | |
| 106 | + | b, err := extproto.Encode(v) | |
| 107 | + | if err != nil { | |
| 108 | + | t.Fatalf("encode: %v", err) | |
| 109 | + | } | |
| 110 | + | if _, err := h.hostW.Write(b); err != nil { | |
| 111 | + | t.Fatalf("write to ext: %v", err) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // handshake performs the hello / hello_ack exchange and drains frames | |
| 116 | + | // until "ready". | |
| 117 | + | func (h *extHarness) handshake(t *testing.T) { | |
| 118 | + | t.Helper() | |
| 119 | + | f := h.next(t) | |
| 120 | + | if f.hdr.Type != "hello" { | |
| 121 | + | t.Fatalf("expected hello, got %q", f.hdr.Type) | |
| 122 | + | } | |
| 123 | + | h.sendToExt(t, extproto.HelloAckFromHost{ | |
| 124 | + | Type: "hello_ack", | |
| 125 | + | ProtocolVersion: extproto.ProtocolVersion, | |
| 126 | + | ZotVersion: "0.0.0-test", | |
| 127 | + | Provider: "anthropic", | |
| 128 | + | Model: "claude-test", | |
| 129 | + | }) | |
| 130 | + | for { | |
| 131 | + | f := h.next(t) | |
| 132 | + | if f.hdr.Type == "ready" { | |
| 133 | + | return | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // ---------- tests ---------- | |
| 139 | + | ||
| 140 | + | // TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a | |
| 141 | + | // well-formed open_panel frame with the correct PanelSpec fields. | |
| 142 | + | func TestOpenPanelEmitsCorrectFrame(t *testing.T) { | |
| 143 | + | h := newHarness("test-ext") | |
| 144 | + | go h.ext.Run() | |
| 145 | + | h.handshake(t) | |
| 146 | + | ||
| 147 | + | go h.ext.OpenPanel("my-panel", "My Title", []string{"line a", "line b"}, "esc close") | |
| 148 | + | ||
| 149 | + | f := h.drainUntil(t, "open_panel") | |
| 150 | + | ||
| 151 | + | var op extproto.OpenPanelFromExt | |
| 152 | + | if err := json.Unmarshal(f.raw, &op); err != nil { | |
| 153 | + | t.Fatalf("unmarshal open_panel: %v", err) | |
| 154 | + | } | |
| 155 | + | if op.Panel.ID != "my-panel" { | |
| 156 | + | t.Errorf("panel id: want %q, got %q", "my-panel", op.Panel.ID) | |
| 157 | + | } | |
| 158 | + | if op.Panel.Title != "My Title" { | |
| 159 | + | t.Errorf("panel title: want %q, got %q", "My Title", op.Panel.Title) | |
| 160 | + | } | |
| 161 | + | if len(op.Panel.Lines) != 2 || op.Panel.Lines[0] != "line a" || op.Panel.Lines[1] != "line b" { | |
| 162 | + | t.Errorf("panel lines: got %v", op.Panel.Lines) | |
| 163 | + | } | |
| 164 | + | if op.Panel.Footer != "esc close" { | |
| 165 | + | t.Errorf("panel footer: want %q, got %q", "esc close", op.Panel.Footer) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | h.hostW.Close() | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // TestBlockingToolWaitsForPanelKey is the core integration test for | |
| 172 | + | // the human-in-the-loop pattern: the tool handler opens a panel, | |
| 173 | + | // blocks on a channel, and only returns a tool_result after a key | |
| 174 | + | // event arrives. | |
| 175 | + | func TestBlockingToolWaitsForPanelKey(t *testing.T) { | |
| 176 | + | h := newHarness("gate-ext") | |
| 177 | + | ||
| 178 | + | const pid = "gate-panel" | |
| 179 | + | const toolCallID = "tc-001" | |
| 180 | + | ||
| 181 | + | approved := make(chan bool, 1) | |
| 182 | + | ||
| 183 | + | h.ext.OnPanelKey(pid, func(key, text string) { | |
| 184 | + | switch { | |
| 185 | + | case key == "rune" && text == "y": | |
| 186 | + | h.ext.ClosePanel(pid) | |
| 187 | + | approved <- true | |
| 188 | + | case key == "rune" && text == "n", key == "esc": | |
| 189 | + | h.ext.ClosePanel(pid) | |
| 190 | + | approved <- false | |
| 191 | + | } | |
| 192 | + | }, func() { approved <- false }) | |
| 193 | + | ||
| 194 | + | h.ext.Tool("gate", "needs approval", | |
| 195 | + | json.RawMessage(`{"type":"object","properties":{}}`), | |
| 196 | + | func(args json.RawMessage) ToolResult { | |
| 197 | + | h.ext.OpenPanel(pid, "Approve?", | |
| 198 | + | []string{" y approve", " n deny"}, "y/n") | |
| 199 | + | if <-approved { | |
| 200 | + | return TextResult("approved") | |
| 201 | + | } | |
| 202 | + | return TextErrorResult("denied") | |
| 203 | + | }) | |
| 204 | + | ||
| 205 | + | go h.ext.Run() | |
| 206 | + | h.handshake(t) | |
| 207 | + | ||
| 208 | + | h.sendToExt(t, extproto.ToolCallFromHost{ | |
| 209 | + | Type: "tool_call", ID: toolCallID, Name: "gate", | |
| 210 | + | Args: json.RawMessage(`{}`), | |
| 211 | + | }) | |
| 212 | + | ||
| 213 | + | // Tool goroutine must open the panel before it can reply. | |
| 214 | + | h.drainUntil(t, "open_panel") | |
| 215 | + | ||
| 216 | + | // Send approval — tool should now unblock and emit tool_result. | |
| 217 | + | h.sendToExt(t, extproto.PanelKeyFromHost{ | |
| 218 | + | Type: "panel_key", PanelID: pid, Key: "rune", Text: "y", | |
| 219 | + | }) | |
| 220 | + | ||
| 221 | + | f := h.drainUntil(t, "tool_result") | |
| 222 | + | var tr extproto.ToolResultFromExt | |
| 223 | + | if err := json.Unmarshal(f.raw, &tr); err != nil { | |
| 224 | + | t.Fatalf("unmarshal tool_result: %v", err) | |
| 225 | + | } | |
| 226 | + | if tr.ID != toolCallID { | |
| 227 | + | t.Errorf("tool_result id: want %q, got %q", toolCallID, tr.ID) | |
| 228 | + | } | |
| 229 | + | if tr.IsError { | |
| 230 | + | t.Errorf("expected success, got is_error=true") | |
| 231 | + | } | |
| 232 | + | if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "approved") { | |
| 233 | + | t.Errorf("expected 'approved' in content, got %+v", tr.Content) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | h.hostW.Close() | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // TestBlockingToolDenied mirrors TestBlockingToolWaitsForPanelKey but | |
| 240 | + | // sends "n" so the tool returns an error result. | |
| 241 | + | func TestBlockingToolDenied(t *testing.T) { | |
| 242 | + | h := newHarness("gate-ext-deny") | |
| 243 | + | ||
| 244 | + | const pid = "deny-panel" | |
| 245 | + | const toolCallID = "tc-002" | |
| 246 | + | ||
| 247 | + | approved := make(chan bool, 1) | |
| 248 | + | ||
| 249 | + | h.ext.OnPanelKey(pid, func(key, text string) { | |
| 250 | + | switch { | |
| 251 | + | case key == "rune" && text == "y": | |
| 252 | + | h.ext.ClosePanel(pid); approved <- true | |
| 253 | + | case key == "rune" && text == "n", key == "esc": | |
| 254 | + | h.ext.ClosePanel(pid); approved <- false | |
| 255 | + | } | |
| 256 | + | }, func() { approved <- false }) | |
| 257 | + | ||
| 258 | + | h.ext.Tool("gate2", "needs approval", | |
| 259 | + | json.RawMessage(`{"type":"object","properties":{}}`), | |
| 260 | + | func(args json.RawMessage) ToolResult { | |
| 261 | + | h.ext.OpenPanel(pid, "Approve?", []string{"y/n"}, "") | |
| 262 | + | if <-approved { | |
| 263 | + | return TextResult("approved") | |
| 264 | + | } | |
| 265 | + | return TextErrorResult("denied") | |
| 266 | + | }) | |
| 267 | + | ||
| 268 | + | go h.ext.Run() | |
| 269 | + | h.handshake(t) | |
| 270 | + | ||
| 271 | + | h.sendToExt(t, extproto.ToolCallFromHost{ | |
| 272 | + | Type: "tool_call", ID: toolCallID, Name: "gate2", | |
| 273 | + | Args: json.RawMessage(`{}`), | |
| 274 | + | }) | |
| 275 | + | ||
| 276 | + | h.drainUntil(t, "open_panel") | |
| 277 | + | ||
| 278 | + | h.sendToExt(t, extproto.PanelKeyFromHost{ | |
| 279 | + | Type: "panel_key", PanelID: pid, Key: "rune", Text: "n", | |
| 280 | + | }) | |
| 281 | + | ||
| 282 | + | f := h.drainUntil(t, "tool_result") | |
| 283 | + | var tr extproto.ToolResultFromExt | |
| 284 | + | if err := json.Unmarshal(f.raw, &tr); err != nil { | |
| 285 | + | t.Fatalf("unmarshal tool_result: %v", err) | |
| 286 | + | } | |
| 287 | + | if !tr.IsError { | |
| 288 | + | t.Errorf("expected is_error=true on denial") | |
| 289 | + | } | |
| 290 | + | if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "denied") { | |
| 291 | + | t.Errorf("expected 'denied' in content, got %+v", tr.Content) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | h.hostW.Close() | |
| 295 | + | } | |
| @@ -0,0 +1,295 @@ | |||||
| 1 | + | package ext | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ---------- test harness ---------- | |||
| 15 | + | ||||
| 16 | + | // extHarness wires an Extension to io.Pipe pairs so a test can play | |||
| 17 | + | // the role of the host: write host→ext frames, read ext→host frames. | |||
| 18 | + | // The scanner runs in a permanent background goroutine and delivers | |||
| 19 | + | // frames over a buffered channel, avoiding the deadlock that would | |||
| 20 | + | // occur if the test goroutine alternated between writing and reading | |||
| 21 | + | // a synchronous pipe. | |||
| 22 | + | type extHarness struct { | |||
| 23 | + | ext *Extension | |||
| 24 | + | hostW *io.PipeWriter // test writes here → ext reads as stdin | |||
| 25 | + | frames chan rawFrame // ext→host frames delivered here | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type rawFrame struct { | |||
| 29 | + | hdr extproto.Frame | |||
| 30 | + | raw []byte | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func newHarness(name string) *extHarness { | |||
| 34 | + | extStdinR, extStdinW := io.Pipe() | |||
| 35 | + | extStdoutR, extStdoutW := io.Pipe() | |||
| 36 | + | ||||
| 37 | + | e := New(name, "0.0.0-test") | |||
| 38 | + | e.in = extStdinR | |||
| 39 | + | e.out = extStdoutW | |||
| 40 | + | e.stderr = io.Discard | |||
| 41 | + | ||||
| 42 | + | h := &extHarness{ | |||
| 43 | + | ext: e, | |||
| 44 | + | hostW: extStdinW, | |||
| 45 | + | frames: make(chan rawFrame, 64), | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // Background reader: scan ext's stdout and push every frame into | |||
| 49 | + | // the channel so the test goroutine never needs to block on the pipe. | |||
| 50 | + | go func() { | |||
| 51 | + | scanner := bufio.NewScanner(extStdoutR) | |||
| 52 | + | scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) | |||
| 53 | + | for scanner.Scan() { | |||
| 54 | + | b := scanner.Bytes() | |||
| 55 | + | cp := make([]byte, len(b)) | |||
| 56 | + | copy(cp, b) | |||
| 57 | + | var f extproto.Frame | |||
| 58 | + | json.Unmarshal(cp, &f) | |||
| 59 | + | h.frames <- rawFrame{f, cp} | |||
| 60 | + | } | |||
| 61 | + | close(h.frames) | |||
| 62 | + | }() | |||
| 63 | + | ||||
| 64 | + | return h | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // next returns the next frame, timing out after 2 s. | |||
| 68 | + | func (h *extHarness) next(t *testing.T) rawFrame { | |||
| 69 | + | t.Helper() | |||
| 70 | + | select { | |||
| 71 | + | case f, ok := <-h.frames: | |||
| 72 | + | if !ok { | |||
| 73 | + | t.Fatal("frame channel closed (ext stdout EOF)") | |||
| 74 | + | } | |||
| 75 | + | return f | |||
| 76 | + | case <-time.After(2 * time.Second): | |||
| 77 | + | t.Fatal("timeout waiting for frame from extension") | |||
| 78 | + | return rawFrame{} | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // drainUntil reads frames until one with type == want arrives. | |||
| 83 | + | func (h *extHarness) drainUntil(t *testing.T, want string) rawFrame { | |||
| 84 | + | t.Helper() | |||
| 85 | + | deadline := time.NewTimer(2 * time.Second) | |||
| 86 | + | defer deadline.Stop() | |||
| 87 | + | for { | |||
| 88 | + | select { | |||
| 89 | + | case f, ok := <-h.frames: | |||
| 90 | + | if !ok { | |||
| 91 | + | t.Fatalf("frame channel closed before seeing %q", want) | |||
| 92 | + | } | |||
| 93 | + | if f.hdr.Type == want { | |||
| 94 | + | return f | |||
| 95 | + | } | |||
| 96 | + | case <-deadline.C: | |||
| 97 | + | t.Fatalf("timeout waiting for frame type %q", want) | |||
| 98 | + | return rawFrame{} | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // sendToExt writes a host→ext frame. | |||
| 104 | + | func (h *extHarness) sendToExt(t *testing.T, v any) { | |||
| 105 | + | t.Helper() | |||
| 106 | + | b, err := extproto.Encode(v) | |||
| 107 | + | if err != nil { | |||
| 108 | + | t.Fatalf("encode: %v", err) | |||
| 109 | + | } | |||
| 110 | + | if _, err := h.hostW.Write(b); err != nil { | |||
| 111 | + | t.Fatalf("write to ext: %v", err) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // handshake performs the hello / hello_ack exchange and drains frames | |||
| 116 | + | // until "ready". | |||
| 117 | + | func (h *extHarness) handshake(t *testing.T) { | |||
| 118 | + | t.Helper() | |||
| 119 | + | f := h.next(t) | |||
| 120 | + | if f.hdr.Type != "hello" { | |||
| 121 | + | t.Fatalf("expected hello, got %q", f.hdr.Type) | |||
| 122 | + | } | |||
| 123 | + | h.sendToExt(t, extproto.HelloAckFromHost{ | |||
| 124 | + | Type: "hello_ack", | |||
| 125 | + | ProtocolVersion: extproto.ProtocolVersion, | |||
| 126 | + | ZotVersion: "0.0.0-test", | |||
| 127 | + | Provider: "anthropic", | |||
| 128 | + | Model: "claude-test", | |||
| 129 | + | }) | |||
| 130 | + | for { | |||
| 131 | + | f := h.next(t) | |||
| 132 | + | if f.hdr.Type == "ready" { | |||
| 133 | + | return | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // ---------- tests ---------- | |||
| 139 | + | ||||
| 140 | + | // TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a | |||
| 141 | + | // well-formed open_panel frame with the correct PanelSpec fields. | |||
| 142 | + | func TestOpenPanelEmitsCorrectFrame(t *testing.T) { | |||
| 143 | + | h := newHarness("test-ext") | |||
| 144 | + | go h.ext.Run() | |||
| 145 | + | h.handshake(t) | |||
| 146 | + | ||||
| 147 | + | go h.ext.OpenPanel("my-panel", "My Title", []string{"line a", "line b"}, "esc close") | |||
| 148 | + | ||||
| 149 | + | f := h.drainUntil(t, "open_panel") | |||
| 150 | + | ||||
| 151 | + | var op extproto.OpenPanelFromExt | |||
| 152 | + | if err := json.Unmarshal(f.raw, &op); err != nil { | |||
| 153 | + | t.Fatalf("unmarshal open_panel: %v", err) | |||
| 154 | + | } | |||
| 155 | + | if op.Panel.ID != "my-panel" { | |||
| 156 | + | t.Errorf("panel id: want %q, got %q", "my-panel", op.Panel.ID) | |||
| 157 | + | } | |||
| 158 | + | if op.Panel.Title != "My Title" { | |||
| 159 | + | t.Errorf("panel title: want %q, got %q", "My Title", op.Panel.Title) | |||
| 160 | + | } | |||
| 161 | + | if len(op.Panel.Lines) != 2 || op.Panel.Lines[0] != "line a" || op.Panel.Lines[1] != "line b" { | |||
| 162 | + | t.Errorf("panel lines: got %v", op.Panel.Lines) | |||
| 163 | + | } | |||
| 164 | + | if op.Panel.Footer != "esc close" { | |||
| 165 | + | t.Errorf("panel footer: want %q, got %q", "esc close", op.Panel.Footer) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | h.hostW.Close() | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // TestBlockingToolWaitsForPanelKey is the core integration test for | |||
| 172 | + | // the human-in-the-loop pattern: the tool handler opens a panel, | |||
| 173 | + | // blocks on a channel, and only returns a tool_result after a key | |||
| 174 | + | // event arrives. | |||
| 175 | + | func TestBlockingToolWaitsForPanelKey(t *testing.T) { | |||
| 176 | + | h := newHarness("gate-ext") | |||
| 177 | + | ||||
| 178 | + | const pid = "gate-panel" | |||
| 179 | + | const toolCallID = "tc-001" | |||
| 180 | + | ||||
| 181 | + | approved := make(chan bool, 1) | |||
| 182 | + | ||||
| 183 | + | h.ext.OnPanelKey(pid, func(key, text string) { | |||
| 184 | + | switch { | |||
| 185 | + | case key == "rune" && text == "y": | |||
| 186 | + | h.ext.ClosePanel(pid) | |||
| 187 | + | approved <- true | |||
| 188 | + | case key == "rune" && text == "n", key == "esc": | |||
| 189 | + | h.ext.ClosePanel(pid) | |||
| 190 | + | approved <- false | |||
| 191 | + | } | |||
| 192 | + | }, func() { approved <- false }) | |||
| 193 | + | ||||
| 194 | + | h.ext.Tool("gate", "needs approval", | |||
| 195 | + | json.RawMessage(`{"type":"object","properties":{}}`), | |||
| 196 | + | func(args json.RawMessage) ToolResult { | |||
| 197 | + | h.ext.OpenPanel(pid, "Approve?", | |||
| 198 | + | []string{" y approve", " n deny"}, "y/n") | |||
| 199 | + | if <-approved { | |||
| 200 | + | return TextResult("approved") | |||
| 201 | + | } | |||
| 202 | + | return TextErrorResult("denied") | |||
| 203 | + | }) | |||
| 204 | + | ||||
| 205 | + | go h.ext.Run() | |||
| 206 | + | h.handshake(t) | |||
| 207 | + | ||||
| 208 | + | h.sendToExt(t, extproto.ToolCallFromHost{ | |||
| 209 | + | Type: "tool_call", ID: toolCallID, Name: "gate", | |||
| 210 | + | Args: json.RawMessage(`{}`), | |||
| 211 | + | }) | |||
| 212 | + | ||||
| 213 | + | // Tool goroutine must open the panel before it can reply. | |||
| 214 | + | h.drainUntil(t, "open_panel") | |||
| 215 | + | ||||
| 216 | + | // Send approval — tool should now unblock and emit tool_result. | |||
| 217 | + | h.sendToExt(t, extproto.PanelKeyFromHost{ | |||
| 218 | + | Type: "panel_key", PanelID: pid, Key: "rune", Text: "y", | |||
| 219 | + | }) | |||
| 220 | + | ||||
| 221 | + | f := h.drainUntil(t, "tool_result") | |||
| 222 | + | var tr extproto.ToolResultFromExt | |||
| 223 | + | if err := json.Unmarshal(f.raw, &tr); err != nil { | |||
| 224 | + | t.Fatalf("unmarshal tool_result: %v", err) | |||
| 225 | + | } | |||
| 226 | + | if tr.ID != toolCallID { | |||
| 227 | + | t.Errorf("tool_result id: want %q, got %q", toolCallID, tr.ID) | |||
| 228 | + | } | |||
| 229 | + | if tr.IsError { | |||
| 230 | + | t.Errorf("expected success, got is_error=true") | |||
| 231 | + | } | |||
| 232 | + | if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "approved") { | |||
| 233 | + | t.Errorf("expected 'approved' in content, got %+v", tr.Content) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | h.hostW.Close() | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // TestBlockingToolDenied mirrors TestBlockingToolWaitsForPanelKey but | |||
| 240 | + | // sends "n" so the tool returns an error result. | |||
| 241 | + | func TestBlockingToolDenied(t *testing.T) { | |||
| 242 | + | h := newHarness("gate-ext-deny") | |||
| 243 | + | ||||
| 244 | + | const pid = "deny-panel" | |||
| 245 | + | const toolCallID = "tc-002" | |||
| 246 | + | ||||
| 247 | + | approved := make(chan bool, 1) | |||
| 248 | + | ||||
| 249 | + | h.ext.OnPanelKey(pid, func(key, text string) { | |||
| 250 | + | switch { | |||
| 251 | + | case key == "rune" && text == "y": | |||
| 252 | + | h.ext.ClosePanel(pid); approved <- true | |||
| 253 | + | case key == "rune" && text == "n", key == "esc": | |||
| 254 | + | h.ext.ClosePanel(pid); approved <- false | |||
| 255 | + | } | |||
| 256 | + | }, func() { approved <- false }) | |||
| 257 | + | ||||
| 258 | + | h.ext.Tool("gate2", "needs approval", | |||
| 259 | + | json.RawMessage(`{"type":"object","properties":{}}`), | |||
| 260 | + | func(args json.RawMessage) ToolResult { | |||
| 261 | + | h.ext.OpenPanel(pid, "Approve?", []string{"y/n"}, "") | |||
| 262 | + | if <-approved { | |||
| 263 | + | return TextResult("approved") | |||
| 264 | + | } | |||
| 265 | + | return TextErrorResult("denied") | |||
| 266 | + | }) | |||
| 267 | + | ||||
| 268 | + | go h.ext.Run() | |||
| 269 | + | h.handshake(t) | |||
| 270 | + | ||||
| 271 | + | h.sendToExt(t, extproto.ToolCallFromHost{ | |||
| 272 | + | Type: "tool_call", ID: toolCallID, Name: "gate2", | |||
| 273 | + | Args: json.RawMessage(`{}`), | |||
| 274 | + | }) | |||
| 275 | + | ||||
| 276 | + | h.drainUntil(t, "open_panel") | |||
| 277 | + | ||||
| 278 | + | h.sendToExt(t, extproto.PanelKeyFromHost{ | |||
| 279 | + | Type: "panel_key", PanelID: pid, Key: "rune", Text: "n", | |||
| 280 | + | }) | |||
| 281 | + | ||||
| 282 | + | f := h.drainUntil(t, "tool_result") | |||
| 283 | + | var tr extproto.ToolResultFromExt | |||
| 284 | + | if err := json.Unmarshal(f.raw, &tr); err != nil { | |||
| 285 | + | t.Fatalf("unmarshal tool_result: %v", err) | |||
| 286 | + | } | |||
| 287 | + | if !tr.IsError { | |||
| 288 | + | t.Errorf("expected is_error=true on denial") | |||
| 289 | + | } | |||
| 290 | + | if len(tr.Content) == 0 || !strings.Contains(tr.Content[0].Text, "denied") { | |||
| 291 | + | t.Errorf("expected 'denied' in content, got %+v", tr.Content) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | h.hostW.Close() | |||
| 295 | + | } | |||
M packages/agent/extensions/manager.go +5 -0
| @@ -786,6 +786,11 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 786 | 786 | } | |
| 787 | 787 | } | |
| 788 | 788 | } | |
| 789 | + | case "open_panel": | |
| 790 | + | var op extproto.OpenPanelFromExt | |
| 791 | + | if err := json.Unmarshal(line, &op); err == nil { | |
| 792 | + | m.hooks.OpenPanel(ext.Manifest.Name, op.Panel) | |
| 793 | + | } | |
| 789 | 794 | case "panel_render": | |
| 790 | 795 | var pr extproto.PanelRenderFromExt | |
| 791 | 796 | if err := json.Unmarshal(line, &pr); err == nil { | |
| @@ -786,6 +786,11 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 786 | } | 786 | } | ||
| 787 | } | 787 | } | ||
| 788 | } | 788 | } | ||
| 789 | + | case "open_panel": | |||
| 790 | + | var op extproto.OpenPanelFromExt | |||
| 791 | + | if err := json.Unmarshal(line, &op); err == nil { | |||
| 792 | + | m.hooks.OpenPanel(ext.Manifest.Name, op.Panel) | |||
| 793 | + | } | |||
| 789 | case "panel_render": | 794 | case "panel_render": | ||
| 790 | var pr extproto.PanelRenderFromExt | 795 | var pr extproto.PanelRenderFromExt | ||
| 791 | if err := json.Unmarshal(line, &pr); err == nil { | 796 | if err := json.Unmarshal(line, &pr); err == nil { | ||
M packages/agent/extensions/manager_test.go +97 -3
| @@ -20,6 +20,8 @@ type stubHooks struct { | |||
| 20 | 20 | notifies []string | |
| 21 | 21 | displays []string | |
| 22 | 22 | clearNotes []string | |
| 23 | + | panels []extproto.PanelSpec | |
| 24 | + | panelExts []string | |
| 23 | 25 | } | |
| 24 | 26 | ||
| 25 | 27 | func (s *stubHooks) Notify(name, level, message string) { | |
| @@ -40,7 +42,12 @@ func (s *stubHooks) ClearNotes(name string) { | |||
| 40 | 42 | defer s.mu.Unlock() | |
| 41 | 43 | s.clearNotes = append(s.clearNotes, name) | |
| 42 | 44 | } | |
| 43 | - | func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {} | |
| 45 | + | func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) { | |
| 46 | + | s.mu.Lock() | |
| 47 | + | defer s.mu.Unlock() | |
| 48 | + | s.panelExts = append(s.panelExts, extName) | |
| 49 | + | s.panels = append(s.panels, spec) | |
| 50 | + | } | |
| 44 | 51 | func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {} | |
| 45 | 52 | func (s *stubHooks) ClosePanel(string, string) {} | |
| 46 | 53 | ||
| @@ -149,8 +156,15 @@ func TestManagerSpawnAndInvoke(t *testing.T) { | |||
| 149 | 156 | time.Sleep(150 * time.Millisecond) | |
| 150 | 157 | ||
| 151 | 158 | cmds := mgr.Commands() | |
| 152 | - | if len(cmds) != 1 || cmds[0].Name != "ping" { | |
| 153 | - | t.Fatalf("expected one command 'ping', got %#v", cmds) | |
| 159 | + | found := false | |
| 160 | + | for _, c := range cmds { | |
| 161 | + | if c.Name == "ping" { | |
| 162 | + | found = true | |
| 163 | + | break | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | if !found { | |
| 167 | + | t.Fatalf("expected command 'ping', got %#v", cmds) | |
| 154 | 168 | } | |
| 155 | 169 | if !mgr.HasCommand("ping") { | |
| 156 | 170 | t.Fatal("HasCommand(\"ping\") = false") | |
| @@ -167,3 +181,83 @@ func TestManagerSpawnAndInvoke(t *testing.T) { | |||
| 167 | 181 | t.Errorf("expected display=pong, got %q", resp.Display) | |
| 168 | 182 | } | |
| 169 | 183 | } | |
| 184 | + | ||
| 185 | + | // TestSpontaneousOpenPanel verifies that an extension sending an | |
| 186 | + | // open_panel frame outside of any command response causes the manager | |
| 187 | + | // to call hooks.OpenPanel with the correct PanelSpec fields. | |
| 188 | + | func TestSpontaneousOpenPanel(t *testing.T) { | |
| 189 | + | if runtime.GOOS == "windows" { | |
| 190 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | tmp := t.TempDir() | |
| 194 | + | extDir := filepath.Join(tmp, "extensions", "panel-mock") | |
| 195 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |
| 196 | + | t.Fatal(err) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // Extension emits hello + ready, then immediately fires a | |
| 200 | + | // spontaneous open_panel, then waits for shutdown. | |
| 201 | + | script := `#!/bin/sh | |
| 202 | + | printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}' | |
| 203 | + | printf '%s\n' '{"type":"ready"}' | |
| 204 | + | printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}' | |
| 205 | + | while IFS= read -r line; do | |
| 206 | + | case "$line" in | |
| 207 | + | *'"type":"shutdown"'*) | |
| 208 | + | printf '%s\n' '{"type":"shutdown_ack"}' | |
| 209 | + | exit 0 | |
| 210 | + | ;; | |
| 211 | + | esac | |
| 212 | + | done | |
| 213 | + | ` | |
| 214 | + | if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { | |
| 215 | + | t.Fatal(err) | |
| 216 | + | } | |
| 217 | + | mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"}) | |
| 218 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil { | |
| 219 | + | t.Fatal(err) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | hooks := &stubHooks{} | |
| 223 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks) | |
| 224 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |
| 225 | + | t.Fatalf("discover errors: %v", errs) | |
| 226 | + | } | |
| 227 | + | defer mgr.Stop(2 * time.Second) | |
| 228 | + | ||
| 229 | + | // Give the extension time to flush its open_panel frame. | |
| 230 | + | deadline := time.Now().Add(2 * time.Second) | |
| 231 | + | for time.Now().Before(deadline) { | |
| 232 | + | hooks.mu.Lock() | |
| 233 | + | n := len(hooks.panels) | |
| 234 | + | hooks.mu.Unlock() | |
| 235 | + | if n > 0 { | |
| 236 | + | break | |
| 237 | + | } | |
| 238 | + | time.Sleep(20 * time.Millisecond) | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | hooks.mu.Lock() | |
| 242 | + | defer hooks.mu.Unlock() | |
| 243 | + | ||
| 244 | + | if len(hooks.panels) == 0 { | |
| 245 | + | t.Fatal("hooks.OpenPanel was never called") | |
| 246 | + | } | |
| 247 | + | spec := hooks.panels[0] | |
| 248 | + | if spec.ID != "test-panel" { | |
| 249 | + | t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID) | |
| 250 | + | } | |
| 251 | + | if spec.Title != "Hello Panel" { | |
| 252 | + | t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title) | |
| 253 | + | } | |
| 254 | + | if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" { | |
| 255 | + | t.Errorf("panel lines: want [line one line two], got %v", spec.Lines) | |
| 256 | + | } | |
| 257 | + | if spec.Footer != "esc close" { | |
| 258 | + | t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer) | |
| 259 | + | } | |
| 260 | + | if hooks.panelExts[0] != "panel-mock" { | |
| 261 | + | t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0]) | |
| 262 | + | } | |
| 263 | + | } | |
| @@ -20,6 +20,8 @@ type stubHooks struct { | |||||
| 20 | notifies []string | 20 | notifies []string | ||
| 21 | displays []string | 21 | displays []string | ||
| 22 | clearNotes []string | 22 | clearNotes []string | ||
| 23 | + | panels []extproto.PanelSpec | |||
| 24 | + | panelExts []string | |||
| 23 | } | 25 | } | ||
| 24 | 26 | ||||
| 25 | func (s *stubHooks) Notify(name, level, message string) { | 27 | func (s *stubHooks) Notify(name, level, message string) { | ||
| @@ -40,7 +42,12 @@ func (s *stubHooks) ClearNotes(name string) { | |||||
| 40 | defer s.mu.Unlock() | 42 | defer s.mu.Unlock() | ||
| 41 | s.clearNotes = append(s.clearNotes, name) | 43 | s.clearNotes = append(s.clearNotes, name) | ||
| 42 | } | 44 | } | ||
| 43 | - | func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {} | 45 | + | func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) { |
| 46 | + | s.mu.Lock() | |||
| 47 | + | defer s.mu.Unlock() | |||
| 48 | + | s.panelExts = append(s.panelExts, extName) | |||
| 49 | + | s.panels = append(s.panels, spec) | |||
| 50 | + | } | |||
| 44 | func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {} | 51 | func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {} | ||
| 45 | func (s *stubHooks) ClosePanel(string, string) {} | 52 | func (s *stubHooks) ClosePanel(string, string) {} | ||
| 46 | 53 | ||||
| @@ -149,8 +156,15 @@ func TestManagerSpawnAndInvoke(t *testing.T) { | |||||
| 149 | time.Sleep(150 * time.Millisecond) | 156 | time.Sleep(150 * time.Millisecond) | ||
| 150 | 157 | ||||
| 151 | cmds := mgr.Commands() | 158 | cmds := mgr.Commands() | ||
| 152 | - | if len(cmds) != 1 || cmds[0].Name != "ping" { | 159 | + | found := false |
| 153 | - | t.Fatalf("expected one command 'ping', got %#v", cmds) | 160 | + | for _, c := range cmds { |
| 161 | + | if c.Name == "ping" { | |||
| 162 | + | found = true | |||
| 163 | + | break | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | if !found { | |||
| 167 | + | t.Fatalf("expected command 'ping', got %#v", cmds) | |||
| 154 | } | 168 | } | ||
| 155 | if !mgr.HasCommand("ping") { | 169 | if !mgr.HasCommand("ping") { | ||
| 156 | t.Fatal("HasCommand(\"ping\") = false") | 170 | t.Fatal("HasCommand(\"ping\") = false") | ||
| @@ -167,3 +181,83 @@ func TestManagerSpawnAndInvoke(t *testing.T) { | |||||
| 167 | t.Errorf("expected display=pong, got %q", resp.Display) | 181 | t.Errorf("expected display=pong, got %q", resp.Display) | ||
| 168 | } | 182 | } | ||
| 169 | } | 183 | } | ||
| 184 | + | ||||
| 185 | + | // TestSpontaneousOpenPanel verifies that an extension sending an | |||
| 186 | + | // open_panel frame outside of any command response causes the manager | |||
| 187 | + | // to call hooks.OpenPanel with the correct PanelSpec fields. | |||
| 188 | + | func TestSpontaneousOpenPanel(t *testing.T) { | |||
| 189 | + | if runtime.GOOS == "windows" { | |||
| 190 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | tmp := t.TempDir() | |||
| 194 | + | extDir := filepath.Join(tmp, "extensions", "panel-mock") | |||
| 195 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |||
| 196 | + | t.Fatal(err) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Extension emits hello + ready, then immediately fires a | |||
| 200 | + | // spontaneous open_panel, then waits for shutdown. | |||
| 201 | + | script := `#!/bin/sh | |||
| 202 | + | printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}' | |||
| 203 | + | printf '%s\n' '{"type":"ready"}' | |||
| 204 | + | printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}' | |||
| 205 | + | while IFS= read -r line; do | |||
| 206 | + | case "$line" in | |||
| 207 | + | *'"type":"shutdown"'*) | |||
| 208 | + | printf '%s\n' '{"type":"shutdown_ack"}' | |||
| 209 | + | exit 0 | |||
| 210 | + | ;; | |||
| 211 | + | esac | |||
| 212 | + | done | |||
| 213 | + | ` | |||
| 214 | + | if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { | |||
| 215 | + | t.Fatal(err) | |||
| 216 | + | } | |||
| 217 | + | mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"}) | |||
| 218 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil { | |||
| 219 | + | t.Fatal(err) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | hooks := &stubHooks{} | |||
| 223 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks) | |||
| 224 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |||
| 225 | + | t.Fatalf("discover errors: %v", errs) | |||
| 226 | + | } | |||
| 227 | + | defer mgr.Stop(2 * time.Second) | |||
| 228 | + | ||||
| 229 | + | // Give the extension time to flush its open_panel frame. | |||
| 230 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 231 | + | for time.Now().Before(deadline) { | |||
| 232 | + | hooks.mu.Lock() | |||
| 233 | + | n := len(hooks.panels) | |||
| 234 | + | hooks.mu.Unlock() | |||
| 235 | + | if n > 0 { | |||
| 236 | + | break | |||
| 237 | + | } | |||
| 238 | + | time.Sleep(20 * time.Millisecond) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | hooks.mu.Lock() | |||
| 242 | + | defer hooks.mu.Unlock() | |||
| 243 | + | ||||
| 244 | + | if len(hooks.panels) == 0 { | |||
| 245 | + | t.Fatal("hooks.OpenPanel was never called") | |||
| 246 | + | } | |||
| 247 | + | spec := hooks.panels[0] | |||
| 248 | + | if spec.ID != "test-panel" { | |||
| 249 | + | t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID) | |||
| 250 | + | } | |||
| 251 | + | if spec.Title != "Hello Panel" { | |||
| 252 | + | t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title) | |||
| 253 | + | } | |||
| 254 | + | if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" { | |||
| 255 | + | t.Errorf("panel lines: want [line one line two], got %v", spec.Lines) | |||
| 256 | + | } | |||
| 257 | + | if spec.Footer != "esc close" { | |||
| 258 | + | t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer) | |||
| 259 | + | } | |||
| 260 | + | if hooks.panelExts[0] != "panel-mock" { | |||
| 261 | + | t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0]) | |||
| 262 | + | } | |||
| 263 | + | } | |||
M packages/agent/extproto/extproto.go +9 -0
| @@ -97,6 +97,15 @@ type PanelSpec struct { | |||
| 97 | 97 | Footer string `json:"footer,omitempty"` | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | + | // OpenPanelFromExt is a spontaneous one-way frame an extension can send at | |
| 101 | + | // any time to open an interactive panel. Unlike the open_panel action inside | |
| 102 | + | // CommandResponseFromExt, this form is uncoupled from any command invocation | |
| 103 | + | // and may be sent from a tool handler goroutine or any background context. | |
| 104 | + | type OpenPanelFromExt struct { | |
| 105 | + | Type string `json:"type"` // "open_panel" | |
| 106 | + | Panel PanelSpec `json:"panel"` | |
| 107 | + | } | |
| 108 | + | ||
| 100 | 109 | type PanelRenderFromExt struct { | |
| 101 | 110 | Type string `json:"type"` | |
| 102 | 111 | PanelID string `json:"panel_id"` | |
| @@ -97,6 +97,15 @@ type PanelSpec struct { | |||||
| 97 | Footer string `json:"footer,omitempty"` | 97 | Footer string `json:"footer,omitempty"` | ||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | + | // OpenPanelFromExt is a spontaneous one-way frame an extension can send at | |||
| 101 | + | // any time to open an interactive panel. Unlike the open_panel action inside | |||
| 102 | + | // CommandResponseFromExt, this form is uncoupled from any command invocation | |||
| 103 | + | // and may be sent from a tool handler goroutine or any background context. | |||
| 104 | + | type OpenPanelFromExt struct { | |||
| 105 | + | Type string `json:"type"` // "open_panel" | |||
| 106 | + | Panel PanelSpec `json:"panel"` | |||
| 107 | + | } | |||
| 108 | + | ||||
| 100 | type PanelRenderFromExt struct { | 109 | type PanelRenderFromExt struct { | ||
| 101 | Type string `json:"type"` | 110 | Type string `json:"type"` | ||
| 102 | PanelID string `json:"panel_id"` | 111 | PanelID string `json:"panel_id"` | ||