Merge #20: spontaneous open_panel for extension panels
Allow extensions to open panels outside slash-command responses, enabling human-in-the-loop tool gates and secret/input collection patterns. Removes the review-only spontaneous panel plan before merge. Co-authored-by: Raymond Gasper <raymondgasper@fastmail.com>
11 files · +923 -3
A examples/extensions/approve/README.md +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | # approve — example zot extension | |
| 2 | + | ||
| 3 | + | Demonstrates the **spontaneous `open_panel`** pattern: an | |
| 4 | + | extension-registered tool opens a panel from inside its handler | |
| 5 | + | goroutine, blocks until the user responds, then returns the result to | |
| 6 | + | the model. | |
| 7 | + | ||
| 8 | + | ## What it does | |
| 9 | + | ||
| 10 | + | Registers one LLM-callable tool: | |
| 11 | + | ||
| 12 | + | ``` | |
| 13 | + | approve_action(action, reason?) | |
| 14 | + | ``` | |
| 15 | + | ||
| 16 | + | When the model calls it, a panel appears in the TUI: | |
| 17 | + | ||
| 18 | + | ``` | |
| 19 | + | ╭─ Approval required ─────────────────────╮ | |
| 20 | + | │ Action: delete /tmp/build │ | |
| 21 | + | │ Reason: cleaning up after the build │ | |
| 22 | + | │ │ | |
| 23 | + | │ y approve n / esc deny │ | |
| 24 | + | ╰──────────────────────────────────────────╯ | |
| 25 | + | y approve n deny esc cancel | |
| 26 | + | ``` | |
| 27 | + | ||
| 28 | + | The model's tool call is held open until the user presses a key. The | |
| 29 | + | model receives `"approved"` or `"denied: user rejected the action"` as | |
| 30 | + | the tool result and replies accordingly. | |
| 31 | + | ||
| 32 | + | ## Build | |
| 33 | + | ||
| 34 | + | ```bash | |
| 35 | + | cd examples/extensions/approve | |
| 36 | + | go build -o approve . | |
| 37 | + | ``` | |
| 38 | + | ||
| 39 | + | ## Install | |
| 40 | + | ||
| 41 | + | ```bash | |
| 42 | + | zot ext install . | |
| 43 | + | ``` | |
| 44 | + | ||
| 45 | + | ## Try it | |
| 46 | + | ||
| 47 | + | In zot, ask: | |
| 48 | + | ||
| 49 | + | > Request approval to delete the temp directory. | |
| 50 | + | ||
| 51 | + | The model calls `approve_action`; the panel opens. Press **y** to | |
| 52 | + | approve, **n** or **esc** to deny. | |
| 53 | + | ||
| 54 | + | > Ask me to approve before running any destructive command. | |
| 55 | + | ||
| 56 | + | The model will start calling `approve_action` before suggesting risky | |
| 57 | + | steps; each call pauses until you respond. | |
| 58 | + | ||
| 59 | + | ## Key points | |
| 60 | + | ||
| 61 | + | - The tool handler calls `e.OpenPanel(...)` directly — no slash command | |
| 62 | + | needed. | |
| 63 | + | - A `chan bool` bridges the panel key handler back to the blocked tool | |
| 64 | + | goroutine; nothing in the wire protocol changes. | |
| 65 | + | - Panel IDs are unique per call so multiple concurrent approvals don't | |
| 66 | + | collide (rare in practice, but handled correctly). | |
| 67 | + | - The `onClose` callback on `OnPanelKey` handles the case where the | |
| 68 | + | user dismisses the panel from the TUI border rather than pressing a | |
| 69 | + | key, treating it as a denial. | |
| 70 | + | ||
| 71 | + | ## See also | |
| 72 | + | ||
| 73 | + | - `examples/extensions/secret` — same pattern with masked text input | |
| 74 | + | (credential collection) | |
| 75 | + | - `examples/extensions/guard` — blocks dangerous tool calls via | |
| 76 | + | interception (no panel) | |
| 77 | + | - `docs/extensions.md` — full protocol reference | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | # approve — example zot extension | |||
| 2 | + | ||||
| 3 | + | Demonstrates the **spontaneous `open_panel`** pattern: an | |||
| 4 | + | extension-registered tool opens a panel from inside its handler | |||
| 5 | + | goroutine, blocks until the user responds, then returns the result to | |||
| 6 | + | the model. | |||
| 7 | + | ||||
| 8 | + | ## What it does | |||
| 9 | + | ||||
| 10 | + | Registers one LLM-callable tool: | |||
| 11 | + | ||||
| 12 | + | ``` | |||
| 13 | + | approve_action(action, reason?) | |||
| 14 | + | ``` | |||
| 15 | + | ||||
| 16 | + | When the model calls it, a panel appears in the TUI: | |||
| 17 | + | ||||
| 18 | + | ``` | |||
| 19 | + | ╭─ Approval required ─────────────────────╮ | |||
| 20 | + | │ Action: delete /tmp/build │ | |||
| 21 | + | │ Reason: cleaning up after the build │ | |||
| 22 | + | │ │ | |||
| 23 | + | │ y approve n / esc deny │ | |||
| 24 | + | ╰──────────────────────────────────────────╯ | |||
| 25 | + | y approve n deny esc cancel | |||
| 26 | + | ``` | |||
| 27 | + | ||||
| 28 | + | The model's tool call is held open until the user presses a key. The | |||
| 29 | + | model receives `"approved"` or `"denied: user rejected the action"` as | |||
| 30 | + | the tool result and replies accordingly. | |||
| 31 | + | ||||
| 32 | + | ## Build | |||
| 33 | + | ||||
| 34 | + | ```bash | |||
| 35 | + | cd examples/extensions/approve | |||
| 36 | + | go build -o approve . | |||
| 37 | + | ``` | |||
| 38 | + | ||||
| 39 | + | ## Install | |||
| 40 | + | ||||
| 41 | + | ```bash | |||
| 42 | + | zot ext install . | |||
| 43 | + | ``` | |||
| 44 | + | ||||
| 45 | + | ## Try it | |||
| 46 | + | ||||
| 47 | + | In zot, ask: | |||
| 48 | + | ||||
| 49 | + | > Request approval to delete the temp directory. | |||
| 50 | + | ||||
| 51 | + | The model calls `approve_action`; the panel opens. Press **y** to | |||
| 52 | + | approve, **n** or **esc** to deny. | |||
| 53 | + | ||||
| 54 | + | > Ask me to approve before running any destructive command. | |||
| 55 | + | ||||
| 56 | + | The model will start calling `approve_action` before suggesting risky | |||
| 57 | + | steps; each call pauses until you respond. | |||
| 58 | + | ||||
| 59 | + | ## Key points | |||
| 60 | + | ||||
| 61 | + | - The tool handler calls `e.OpenPanel(...)` directly — no slash command | |||
| 62 | + | needed. | |||
| 63 | + | - A `chan bool` bridges the panel key handler back to the blocked tool | |||
| 64 | + | goroutine; nothing in the wire protocol changes. | |||
| 65 | + | - Panel IDs are unique per call so multiple concurrent approvals don't | |||
| 66 | + | collide (rare in practice, but handled correctly). | |||
| 67 | + | - The `onClose` callback on `OnPanelKey` handles the case where the | |||
| 68 | + | user dismisses the panel from the TUI border rather than pressing a | |||
| 69 | + | key, treating it as a denial. | |||
| 70 | + | ||||
| 71 | + | ## See also | |||
| 72 | + | ||||
| 73 | + | - `examples/extensions/secret` — same pattern with masked text input | |||
| 74 | + | (credential collection) | |||
| 75 | + | - `examples/extensions/guard` — blocks dangerous tool calls via | |||
| 76 | + | interception (no panel) | |||
| 77 | + | - `docs/extensions.md` — full protocol reference | |||
A examples/extensions/approve/extension.json +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "approve", | |
| 3 | + | "version": "1.0.0", | |
| 4 | + | "exec": "./approve", | |
| 5 | + | "language": "go", | |
| 6 | + | "description": "human-in-the-loop approval gate: the model must get user sign-off before a tool runs", | |
| 7 | + | "enabled": true | |
| 8 | + | } | |
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "approve", | |||
| 3 | + | "version": "1.0.0", | |||
| 4 | + | "exec": "./approve", | |||
| 5 | + | "language": "go", | |||
| 6 | + | "description": "human-in-the-loop approval gate: the model must get user sign-off before a tool runs", | |||
| 7 | + | "enabled": true | |||
| 8 | + | } | |||
A examples/extensions/approve/main.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | // approve — demonstrates the spontaneous open_panel pattern for | |
| 2 | + | // human-in-the-loop tool approval gates. | |
| 3 | + | // | |
| 4 | + | // Registers one LLM-callable tool: | |
| 5 | + | // | |
| 6 | + | // approve_action(action: string, reason: string) | |
| 7 | + | // | |
| 8 | + | // When the model calls it, a panel opens asking the user to approve | |
| 9 | + | // or deny. The tool goroutine blocks until the user responds; the | |
| 10 | + | // model only sees the result after the user has acted. | |
| 11 | + | // | |
| 12 | + | // Build: | |
| 13 | + | // | |
| 14 | + | // cd examples/extensions/approve | |
| 15 | + | // go build -o approve . | |
| 16 | + | // | |
| 17 | + | // Install: | |
| 18 | + | // | |
| 19 | + | // zot ext install . | |
| 20 | + | // | |
| 21 | + | // Try it — ask zot something like: | |
| 22 | + | // | |
| 23 | + | // "Request approval to delete the temp directory." | |
| 24 | + | // | |
| 25 | + | // The model will call approve_action; a panel appears in the TUI. Press | |
| 26 | + | // y to approve or n (or esc) to deny. The model receives "approved" or | |
| 27 | + | // "denied: user rejected the action" as the tool result and responds | |
| 28 | + | // accordingly. | |
| 29 | + | package main | |
| 30 | + | ||
| 31 | + | import ( | |
| 32 | + | "encoding/json" | |
| 33 | + | "fmt" | |
| 34 | + | "os" | |
| 35 | + | "strings" | |
| 36 | + | "sync" | |
| 37 | + | ||
| 38 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | const schema = `{ | |
| 42 | + | "type": "object", | |
| 43 | + | "properties": { | |
| 44 | + | "action": { | |
| 45 | + | "type": "string", | |
| 46 | + | "description": "Short description of what is about to happen, shown to the user." | |
| 47 | + | }, | |
| 48 | + | "reason": { | |
| 49 | + | "type": "string", | |
| 50 | + | "description": "Why the action is needed, shown to the user." | |
| 51 | + | } | |
| 52 | + | }, | |
| 53 | + | "required": ["action"] | |
| 54 | + | }` | |
| 55 | + | ||
| 56 | + | func main() { | |
| 57 | + | e := ext.New("approve", "1.0.0") | |
| 58 | + | ||
| 59 | + | // counter is used to generate unique panel IDs so that concurrent | |
| 60 | + | // tool calls (unlikely in practice but possible) don't collide. | |
| 61 | + | var mu sync.Mutex | |
| 62 | + | var counter int | |
| 63 | + | nextPID := func() string { | |
| 64 | + | mu.Lock() | |
| 65 | + | defer mu.Unlock() | |
| 66 | + | counter++ | |
| 67 | + | return fmt.Sprintf("approve-%d", counter) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | e.Tool("approve_action", "Ask the user to approve or deny an action before it proceeds.", json.RawMessage(schema), | |
| 71 | + | func(args json.RawMessage) ext.ToolResult { | |
| 72 | + | var in struct { | |
| 73 | + | Action string `json:"action"` | |
| 74 | + | Reason string `json:"reason"` | |
| 75 | + | } | |
| 76 | + | if err := json.Unmarshal(args, &in); err != nil { | |
| 77 | + | return ext.TextErrorResult("invalid args: " + err.Error()) | |
| 78 | + | } | |
| 79 | + | if strings.TrimSpace(in.Action) == "" { | |
| 80 | + | return ext.TextErrorResult("action is required") | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | pid := nextPID() | |
| 84 | + | decision := make(chan bool, 1) | |
| 85 | + | ||
| 86 | + | buildLines := func(hint string) []string { | |
| 87 | + | lines := []string{ | |
| 88 | + | " Action: " + in.Action, | |
| 89 | + | } | |
| 90 | + | if strings.TrimSpace(in.Reason) != "" { | |
| 91 | + | lines = append(lines, " Reason: "+in.Reason) | |
| 92 | + | } | |
| 93 | + | lines = append(lines, "") | |
| 94 | + | lines = append(lines, " "+hint) | |
| 95 | + | return lines | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | const footer = "● this panel has focus — y approve n deny esc cancel" | |
| 99 | + | const prompt = "› approve this action? [y/n]" | |
| 100 | + | const badKey = "› unrecognised key — press y to approve or n to deny" | |
| 101 | + | ||
| 102 | + | // Register key handler before opening the panel so there | |
| 103 | + | // is no window where a key could arrive unhandled. | |
| 104 | + | e.OnPanelKey(pid, func(key, text string) { | |
| 105 | + | switch { | |
| 106 | + | case key == "rune" && strings.ToLower(text) == "y": | |
| 107 | + | e.ClosePanel(pid) | |
| 108 | + | decision <- true | |
| 109 | + | case key == "rune" && strings.ToLower(text) == "n", | |
| 110 | + | key == "esc": | |
| 111 | + | e.ClosePanel(pid) | |
| 112 | + | decision <- false | |
| 113 | + | default: | |
| 114 | + | // Unknown key: re-render with a nudge so the user | |
| 115 | + | // knows the panel is alive and what to press. | |
| 116 | + | e.RenderPanel(pid, "Approval required", | |
| 117 | + | buildLines(badKey), footer) | |
| 118 | + | } | |
| 119 | + | }, func() { | |
| 120 | + | // Host closed the panel (e.g. user navigated away). | |
| 121 | + | select { | |
| 122 | + | case decision <- false: | |
| 123 | + | default: | |
| 124 | + | } | |
| 125 | + | }) | |
| 126 | + | ||
| 127 | + | // Open the panel spontaneously from inside the tool handler. | |
| 128 | + | e.OpenPanel(pid, "Approval required", buildLines(prompt), footer) | |
| 129 | + | ||
| 130 | + | // Block until the user responds. | |
| 131 | + | if <-decision { | |
| 132 | + | return ext.TextResult("approved") | |
| 133 | + | } | |
| 134 | + | return ext.TextErrorResult("denied: user rejected the action") | |
| 135 | + | }) | |
| 136 | + | ||
| 137 | + | if err := e.Run(); err != nil { | |
| 138 | + | e.Logf("fatal: %v", err) | |
| 139 | + | os.Exit(1) | |
| 140 | + | } | |
| 141 | + | } | |
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | // approve — demonstrates the spontaneous open_panel pattern for | |||
| 2 | + | // human-in-the-loop tool approval gates. | |||
| 3 | + | // | |||
| 4 | + | // Registers one LLM-callable tool: | |||
| 5 | + | // | |||
| 6 | + | // approve_action(action: string, reason: string) | |||
| 7 | + | // | |||
| 8 | + | // When the model calls it, a panel opens asking the user to approve | |||
| 9 | + | // or deny. The tool goroutine blocks until the user responds; the | |||
| 10 | + | // model only sees the result after the user has acted. | |||
| 11 | + | // | |||
| 12 | + | // Build: | |||
| 13 | + | // | |||
| 14 | + | // cd examples/extensions/approve | |||
| 15 | + | // go build -o approve . | |||
| 16 | + | // | |||
| 17 | + | // Install: | |||
| 18 | + | // | |||
| 19 | + | // zot ext install . | |||
| 20 | + | // | |||
| 21 | + | // Try it — ask zot something like: | |||
| 22 | + | // | |||
| 23 | + | // "Request approval to delete the temp directory." | |||
| 24 | + | // | |||
| 25 | + | // The model will call approve_action; a panel appears in the TUI. Press | |||
| 26 | + | // y to approve or n (or esc) to deny. The model receives "approved" or | |||
| 27 | + | // "denied: user rejected the action" as the tool result and responds | |||
| 28 | + | // accordingly. | |||
| 29 | + | package main | |||
| 30 | + | ||||
| 31 | + | import ( | |||
| 32 | + | "encoding/json" | |||
| 33 | + | "fmt" | |||
| 34 | + | "os" | |||
| 35 | + | "strings" | |||
| 36 | + | "sync" | |||
| 37 | + | ||||
| 38 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | const schema = `{ | |||
| 42 | + | "type": "object", | |||
| 43 | + | "properties": { | |||
| 44 | + | "action": { | |||
| 45 | + | "type": "string", | |||
| 46 | + | "description": "Short description of what is about to happen, shown to the user." | |||
| 47 | + | }, | |||
| 48 | + | "reason": { | |||
| 49 | + | "type": "string", | |||
| 50 | + | "description": "Why the action is needed, shown to the user." | |||
| 51 | + | } | |||
| 52 | + | }, | |||
| 53 | + | "required": ["action"] | |||
| 54 | + | }` | |||
| 55 | + | ||||
| 56 | + | func main() { | |||
| 57 | + | e := ext.New("approve", "1.0.0") | |||
| 58 | + | ||||
| 59 | + | // counter is used to generate unique panel IDs so that concurrent | |||
| 60 | + | // tool calls (unlikely in practice but possible) don't collide. | |||
| 61 | + | var mu sync.Mutex | |||
| 62 | + | var counter int | |||
| 63 | + | nextPID := func() string { | |||
| 64 | + | mu.Lock() | |||
| 65 | + | defer mu.Unlock() | |||
| 66 | + | counter++ | |||
| 67 | + | return fmt.Sprintf("approve-%d", counter) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | e.Tool("approve_action", "Ask the user to approve or deny an action before it proceeds.", json.RawMessage(schema), | |||
| 71 | + | func(args json.RawMessage) ext.ToolResult { | |||
| 72 | + | var in struct { | |||
| 73 | + | Action string `json:"action"` | |||
| 74 | + | Reason string `json:"reason"` | |||
| 75 | + | } | |||
| 76 | + | if err := json.Unmarshal(args, &in); err != nil { | |||
| 77 | + | return ext.TextErrorResult("invalid args: " + err.Error()) | |||
| 78 | + | } | |||
| 79 | + | if strings.TrimSpace(in.Action) == "" { | |||
| 80 | + | return ext.TextErrorResult("action is required") | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | pid := nextPID() | |||
| 84 | + | decision := make(chan bool, 1) | |||
| 85 | + | ||||
| 86 | + | buildLines := func(hint string) []string { | |||
| 87 | + | lines := []string{ | |||
| 88 | + | " Action: " + in.Action, | |||
| 89 | + | } | |||
| 90 | + | if strings.TrimSpace(in.Reason) != "" { | |||
| 91 | + | lines = append(lines, " Reason: "+in.Reason) | |||
| 92 | + | } | |||
| 93 | + | lines = append(lines, "") | |||
| 94 | + | lines = append(lines, " "+hint) | |||
| 95 | + | return lines | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | const footer = "● this panel has focus — y approve n deny esc cancel" | |||
| 99 | + | const prompt = "› approve this action? [y/n]" | |||
| 100 | + | const badKey = "› unrecognised key — press y to approve or n to deny" | |||
| 101 | + | ||||
| 102 | + | // Register key handler before opening the panel so there | |||
| 103 | + | // is no window where a key could arrive unhandled. | |||
| 104 | + | e.OnPanelKey(pid, func(key, text string) { | |||
| 105 | + | switch { | |||
| 106 | + | case key == "rune" && strings.ToLower(text) == "y": | |||
| 107 | + | e.ClosePanel(pid) | |||
| 108 | + | decision <- true | |||
| 109 | + | case key == "rune" && strings.ToLower(text) == "n", | |||
| 110 | + | key == "esc": | |||
| 111 | + | e.ClosePanel(pid) | |||
| 112 | + | decision <- false | |||
| 113 | + | default: | |||
| 114 | + | // Unknown key: re-render with a nudge so the user | |||
| 115 | + | // knows the panel is alive and what to press. | |||
| 116 | + | e.RenderPanel(pid, "Approval required", | |||
| 117 | + | buildLines(badKey), footer) | |||
| 118 | + | } | |||
| 119 | + | }, func() { | |||
| 120 | + | // Host closed the panel (e.g. user navigated away). | |||
| 121 | + | select { | |||
| 122 | + | case decision <- false: | |||
| 123 | + | default: | |||
| 124 | + | } | |||
| 125 | + | }) | |||
| 126 | + | ||||
| 127 | + | // Open the panel spontaneously from inside the tool handler. | |||
| 128 | + | e.OpenPanel(pid, "Approval required", buildLines(prompt), footer) | |||
| 129 | + | ||||
| 130 | + | // Block until the user responds. | |||
| 131 | + | if <-decision { | |||
| 132 | + | return ext.TextResult("approved") | |||
| 133 | + | } | |||
| 134 | + | return ext.TextErrorResult("denied: user rejected the action") | |||
| 135 | + | }) | |||
| 136 | + | ||||
| 137 | + | if err := e.Run(); err != nil { | |||
| 138 | + | e.Logf("fatal: %v", err) | |||
| 139 | + | os.Exit(1) | |||
| 140 | + | } | |||
| 141 | + | } | |||
A examples/extensions/secret/README.md +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | # secret — example zot extension | |
| 2 | + | ||
| 3 | + | Demonstrates **secret collection via a masked panel**: the model asks | |
| 4 | + | for a resource that needs a credential, the extension collects the | |
| 5 | + | credential directly from the user inside a panel, uses it to perform | |
| 6 | + | the operation, and returns only the outcome to the model. The secret | |
| 7 | + | is never written to any JSON frame or the transcript. | |
| 8 | + | ||
| 9 | + | ## What it does | |
| 10 | + | ||
| 11 | + | Registers one LLM-callable tool: | |
| 12 | + | ||
| 13 | + | ``` | |
| 14 | + | fetch_with_password(url: string) | |
| 15 | + | ``` | |
| 16 | + | ||
| 17 | + | When the model calls it, a masked password panel opens: | |
| 18 | + | ||
| 19 | + | ``` | |
| 20 | + | ╭─ Password required ─────────────────────╮ | |
| 21 | + | │ URL: https://internal.example.com │ | |
| 22 | + | │ │ | |
| 23 | + | │ Password: ●●●●●●●▌ │ | |
| 24 | + | ╰──────────────────────────────────────────╯ | |
| 25 | + | type password enter confirm esc cancel | |
| 26 | + | ``` | |
| 27 | + | ||
| 28 | + | The panel re-renders after every keystroke, showing bullets instead of | |
| 29 | + | characters. Pressing Enter unblocks the tool goroutine, which uses the | |
| 30 | + | password directly (in `doFetch`) and returns only the fetch outcome to | |
| 31 | + | the model. | |
| 32 | + | ||
| 33 | + | ## Security property | |
| 34 | + | ||
| 35 | + | The password lives only in the extension process's memory. It is never | |
| 36 | + | serialised into a JSON frame, never appears in the transcript, and | |
| 37 | + | never reaches the model. The model sees: | |
| 38 | + | ||
| 39 | + | ``` | |
| 40 | + | fetched https://internal.example.com successfully (password was 9 characters, not shown) | |
| 41 | + | ``` | |
| 42 | + | ||
| 43 | + | ## Build | |
| 44 | + | ||
| 45 | + | ```bash | |
| 46 | + | cd examples/extensions/secret | |
| 47 | + | go build -o secret . | |
| 48 | + | ``` | |
| 49 | + | ||
| 50 | + | ## Install | |
| 51 | + | ||
| 52 | + | ```bash | |
| 53 | + | zot ext install . | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | ## Try it | |
| 57 | + | ||
| 58 | + | In zot, ask: | |
| 59 | + | ||
| 60 | + | > Fetch https://internal.example.com/report — it needs a password. | |
| 61 | + | ||
| 62 | + | The model calls `fetch_with_password`; the masked panel opens. Type | |
| 63 | + | anything and press **Enter**. The model receives the result; the | |
| 64 | + | password is gone. | |
| 65 | + | ||
| 66 | + | ## Adapting to real use | |
| 67 | + | ||
| 68 | + | Replace `doFetch` in `main.go` with a real HTTP request: | |
| 69 | + | ||
| 70 | + | ```go | |
| 71 | + | func doFetch(url, password string) ext.ToolResult { | |
| 72 | + | req, _ := http.NewRequest("GET", url, nil) | |
| 73 | + | req.SetBasicAuth("user", password) | |
| 74 | + | resp, err := http.DefaultClient.Do(req) | |
| 75 | + | if err != nil { | |
| 76 | + | return ext.TextErrorResult("fetch failed: " + err.Error()) | |
| 77 | + | } | |
| 78 | + | defer resp.Body.Close() | |
| 79 | + | body, _ := io.ReadAll(resp.Body) | |
| 80 | + | return ext.TextResult(string(body)) | |
| 81 | + | } | |
| 82 | + | ``` | |
| 83 | + | ||
| 84 | + | The same pattern works for any credential type: API tokens, SSH | |
| 85 | + | passphrases, TOTP codes, or freeform override strings. | |
| 86 | + | ||
| 87 | + | ## See also | |
| 88 | + | ||
| 89 | + | - `examples/extensions/approve` — same pattern for approve/deny gates | |
| 90 | + | (no text input, just y/n) | |
| 91 | + | - `docs/extensions.md` — full protocol reference | |
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | # secret — example zot extension | |||
| 2 | + | ||||
| 3 | + | Demonstrates **secret collection via a masked panel**: the model asks | |||
| 4 | + | for a resource that needs a credential, the extension collects the | |||
| 5 | + | credential directly from the user inside a panel, uses it to perform | |||
| 6 | + | the operation, and returns only the outcome to the model. The secret | |||
| 7 | + | is never written to any JSON frame or the transcript. | |||
| 8 | + | ||||
| 9 | + | ## What it does | |||
| 10 | + | ||||
| 11 | + | Registers one LLM-callable tool: | |||
| 12 | + | ||||
| 13 | + | ``` | |||
| 14 | + | fetch_with_password(url: string) | |||
| 15 | + | ``` | |||
| 16 | + | ||||
| 17 | + | When the model calls it, a masked password panel opens: | |||
| 18 | + | ||||
| 19 | + | ``` | |||
| 20 | + | ╭─ Password required ─────────────────────╮ | |||
| 21 | + | │ URL: https://internal.example.com │ | |||
| 22 | + | │ │ | |||
| 23 | + | │ Password: ●●●●●●●▌ │ | |||
| 24 | + | ╰──────────────────────────────────────────╯ | |||
| 25 | + | type password enter confirm esc cancel | |||
| 26 | + | ``` | |||
| 27 | + | ||||
| 28 | + | The panel re-renders after every keystroke, showing bullets instead of | |||
| 29 | + | characters. Pressing Enter unblocks the tool goroutine, which uses the | |||
| 30 | + | password directly (in `doFetch`) and returns only the fetch outcome to | |||
| 31 | + | the model. | |||
| 32 | + | ||||
| 33 | + | ## Security property | |||
| 34 | + | ||||
| 35 | + | The password lives only in the extension process's memory. It is never | |||
| 36 | + | serialised into a JSON frame, never appears in the transcript, and | |||
| 37 | + | never reaches the model. The model sees: | |||
| 38 | + | ||||
| 39 | + | ``` | |||
| 40 | + | fetched https://internal.example.com successfully (password was 9 characters, not shown) | |||
| 41 | + | ``` | |||
| 42 | + | ||||
| 43 | + | ## Build | |||
| 44 | + | ||||
| 45 | + | ```bash | |||
| 46 | + | cd examples/extensions/secret | |||
| 47 | + | go build -o secret . | |||
| 48 | + | ``` | |||
| 49 | + | ||||
| 50 | + | ## Install | |||
| 51 | + | ||||
| 52 | + | ```bash | |||
| 53 | + | zot ext install . | |||
| 54 | + | ``` | |||
| 55 | + | ||||
| 56 | + | ## Try it | |||
| 57 | + | ||||
| 58 | + | In zot, ask: | |||
| 59 | + | ||||
| 60 | + | > Fetch https://internal.example.com/report — it needs a password. | |||
| 61 | + | ||||
| 62 | + | The model calls `fetch_with_password`; the masked panel opens. Type | |||
| 63 | + | anything and press **Enter**. The model receives the result; the | |||
| 64 | + | password is gone. | |||
| 65 | + | ||||
| 66 | + | ## Adapting to real use | |||
| 67 | + | ||||
| 68 | + | Replace `doFetch` in `main.go` with a real HTTP request: | |||
| 69 | + | ||||
| 70 | + | ```go | |||
| 71 | + | func doFetch(url, password string) ext.ToolResult { | |||
| 72 | + | req, _ := http.NewRequest("GET", url, nil) | |||
| 73 | + | req.SetBasicAuth("user", password) | |||
| 74 | + | resp, err := http.DefaultClient.Do(req) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return ext.TextErrorResult("fetch failed: " + err.Error()) | |||
| 77 | + | } | |||
| 78 | + | defer resp.Body.Close() | |||
| 79 | + | body, _ := io.ReadAll(resp.Body) | |||
| 80 | + | return ext.TextResult(string(body)) | |||
| 81 | + | } | |||
| 82 | + | ``` | |||
| 83 | + | ||||
| 84 | + | The same pattern works for any credential type: API tokens, SSH | |||
| 85 | + | passphrases, TOTP codes, or freeform override strings. | |||
| 86 | + | ||||
| 87 | + | ## See also | |||
| 88 | + | ||||
| 89 | + | - `examples/extensions/approve` — same pattern for approve/deny gates | |||
| 90 | + | (no text input, just y/n) | |||
| 91 | + | - `docs/extensions.md` — full protocol reference | |||
A examples/extensions/secret/extension.json +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "secret", | |
| 3 | + | "version": "1.0.0", | |
| 4 | + | "exec": "./secret", | |
| 5 | + | "language": "go", | |
| 6 | + | "description": "collects secrets from the user via a masked panel; the model never sees the value", | |
| 7 | + | "enabled": true | |
| 8 | + | } | |
| @@ -0,0 +1,8 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "secret", | |||
| 3 | + | "version": "1.0.0", | |||
| 4 | + | "exec": "./secret", | |||
| 5 | + | "language": "go", | |||
| 6 | + | "description": "collects secrets from the user via a masked panel; the model never sees the value", | |||
| 7 | + | "enabled": true | |||
| 8 | + | } | |||
A examples/extensions/secret/main.go +181 -0
| @@ -0,0 +1,181 @@ | |||
| 1 | + | // secret — demonstrates collecting a secret from the user via a | |
| 2 | + | // masked panel. The model never sees the value; it only sees the | |
| 3 | + | // outcome (success or failure). | |
| 4 | + | // | |
| 5 | + | // Registers one LLM-callable tool: | |
| 6 | + | // | |
| 7 | + | // fetch_with_password(url: string) | |
| 8 | + | // | |
| 9 | + | // When the model calls it, a panel opens with a masked password | |
| 10 | + | // field. The user types the password and presses Enter; the tool | |
| 11 | + | // goroutine uses it directly to perform a (fake) authenticated fetch | |
| 12 | + | // and returns only "fetched successfully" or an error to the model. | |
| 13 | + | // The password exists only in the extension process's memory and is | |
| 14 | + | // never written to any JSON frame or the transcript. | |
| 15 | + | // | |
| 16 | + | // Build: | |
| 17 | + | // | |
| 18 | + | // cd examples/extensions/secret | |
| 19 | + | // go build -o secret . | |
| 20 | + | // | |
| 21 | + | // Install: | |
| 22 | + | // | |
| 23 | + | // zot ext install . | |
| 24 | + | // | |
| 25 | + | // Try it — ask zot something like: | |
| 26 | + | // | |
| 27 | + | // "Fetch https://internal.example.com/report — it needs a password." | |
| 28 | + | // | |
| 29 | + | // The model calls fetch_with_password; a masked password panel opens. | |
| 30 | + | // Type anything and press Enter. The model receives the result without | |
| 31 | + | // ever seeing what you typed. | |
| 32 | + | package main | |
| 33 | + | ||
| 34 | + | import ( | |
| 35 | + | "encoding/json" | |
| 36 | + | "fmt" | |
| 37 | + | "os" | |
| 38 | + | "strings" | |
| 39 | + | "sync" | |
| 40 | + | ||
| 41 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | const schema = `{ | |
| 45 | + | "type": "object", | |
| 46 | + | "properties": { | |
| 47 | + | "url": { | |
| 48 | + | "type": "string", | |
| 49 | + | "description": "The URL to fetch." | |
| 50 | + | } | |
| 51 | + | }, | |
| 52 | + | "required": ["url"] | |
| 53 | + | }` | |
| 54 | + | ||
| 55 | + | func main() { | |
| 56 | + | e := ext.New("secret", "1.0.0") | |
| 57 | + | ||
| 58 | + | var mu sync.Mutex | |
| 59 | + | var counter int | |
| 60 | + | nextPID := func() string { | |
| 61 | + | mu.Lock() | |
| 62 | + | defer mu.Unlock() | |
| 63 | + | counter++ | |
| 64 | + | return fmt.Sprintf("secret-%d", counter) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | e.Tool("fetch_with_password", | |
| 68 | + | "Fetch a URL that requires a password. The password is collected directly from the user and never exposed to the model.", | |
| 69 | + | json.RawMessage(schema), | |
| 70 | + | func(args json.RawMessage) ext.ToolResult { | |
| 71 | + | var in struct { | |
| 72 | + | URL string `json:"url"` | |
| 73 | + | } | |
| 74 | + | if err := json.Unmarshal(args, &in); err != nil { | |
| 75 | + | return ext.TextErrorResult("invalid args: " + err.Error()) | |
| 76 | + | } | |
| 77 | + | if strings.TrimSpace(in.URL) == "" { | |
| 78 | + | return ext.TextErrorResult("url is required") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | pid := nextPID() | |
| 82 | + | ||
| 83 | + | type result struct { | |
| 84 | + | password string | |
| 85 | + | ok bool | |
| 86 | + | } | |
| 87 | + | ch := make(chan result, 1) | |
| 88 | + | ||
| 89 | + | // input and inputMu are owned by the key handler goroutine. | |
| 90 | + | var inputMu sync.Mutex | |
| 91 | + | var input string | |
| 92 | + | ||
| 93 | + | render := func() { | |
| 94 | + | inputMu.Lock() | |
| 95 | + | masked := strings.Repeat("●", len([]rune(input))) | |
| 96 | + | inputMu.Unlock() | |
| 97 | + | e.RenderPanel(pid, "Password required", | |
| 98 | + | []string{ | |
| 99 | + | " URL: " + in.URL, | |
| 100 | + | "", | |
| 101 | + | " Password: " + masked + "▌", | |
| 102 | + | }, | |
| 103 | + | "type password enter confirm esc cancel") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | e.OnPanelKey(pid, func(key, text string) { | |
| 107 | + | inputMu.Lock() | |
| 108 | + | switch key { | |
| 109 | + | case "rune": | |
| 110 | + | input += text | |
| 111 | + | inputMu.Unlock() | |
| 112 | + | render() | |
| 113 | + | return | |
| 114 | + | case "backspace": | |
| 115 | + | if len(input) > 0 { | |
| 116 | + | r := []rune(input) | |
| 117 | + | input = string(r[:len(r)-1]) | |
| 118 | + | } | |
| 119 | + | inputMu.Unlock() | |
| 120 | + | render() | |
| 121 | + | return | |
| 122 | + | case "enter": | |
| 123 | + | password := input | |
| 124 | + | inputMu.Unlock() | |
| 125 | + | e.ClosePanel(pid) | |
| 126 | + | ch <- result{password: password, ok: true} | |
| 127 | + | return | |
| 128 | + | case "esc": | |
| 129 | + | inputMu.Unlock() | |
| 130 | + | e.ClosePanel(pid) | |
| 131 | + | ch <- result{} | |
| 132 | + | return | |
| 133 | + | default: | |
| 134 | + | inputMu.Unlock() | |
| 135 | + | } | |
| 136 | + | }, func() { | |
| 137 | + | // Panel closed by host (user navigated away). | |
| 138 | + | select { | |
| 139 | + | case ch <- result{}: | |
| 140 | + | default: | |
| 141 | + | } | |
| 142 | + | }) | |
| 143 | + | ||
| 144 | + | // Open the panel from inside the tool handler. | |
| 145 | + | e.OpenPanel(pid, "Password required", | |
| 146 | + | []string{ | |
| 147 | + | " URL: " + in.URL, | |
| 148 | + | "", | |
| 149 | + | " Password: ▌", | |
| 150 | + | }, | |
| 151 | + | "type password enter confirm esc cancel") | |
| 152 | + | ||
| 153 | + | // Block until the user submits or cancels. | |
| 154 | + | r := <-ch | |
| 155 | + | if !r.ok { | |
| 156 | + | return ext.TextErrorResult("cancelled: user did not provide a password") | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Use the password directly here. It never leaves this | |
| 160 | + | // process and is never written to any frame or transcript. | |
| 161 | + | return doFetch(in.URL, r.password) | |
| 162 | + | }) | |
| 163 | + | ||
| 164 | + | if err := e.Run(); err != nil { | |
| 165 | + | e.Logf("fatal: %v", err) | |
| 166 | + | os.Exit(1) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // doFetch is a stub. Replace with a real http.Get + BasicAuth or | |
| 171 | + | // token header in production use. | |
| 172 | + | func doFetch(url, password string) ext.ToolResult { | |
| 173 | + | if password == "" { | |
| 174 | + | return ext.TextErrorResult("fetch failed: empty password") | |
| 175 | + | } | |
| 176 | + | // Demonstrate that we *have* the password without logging it. | |
| 177 | + | return ext.TextResult(fmt.Sprintf( | |
| 178 | + | "fetched %s successfully (password was %d characters, not shown)", | |
| 179 | + | url, len([]rune(password)), | |
| 180 | + | )) | |
| 181 | + | } | |
| @@ -0,0 +1,181 @@ | |||||
| 1 | + | // secret — demonstrates collecting a secret from the user via a | |||
| 2 | + | // masked panel. The model never sees the value; it only sees the | |||
| 3 | + | // outcome (success or failure). | |||
| 4 | + | // | |||
| 5 | + | // Registers one LLM-callable tool: | |||
| 6 | + | // | |||
| 7 | + | // fetch_with_password(url: string) | |||
| 8 | + | // | |||
| 9 | + | // When the model calls it, a panel opens with a masked password | |||
| 10 | + | // field. The user types the password and presses Enter; the tool | |||
| 11 | + | // goroutine uses it directly to perform a (fake) authenticated fetch | |||
| 12 | + | // and returns only "fetched successfully" or an error to the model. | |||
| 13 | + | // The password exists only in the extension process's memory and is | |||
| 14 | + | // never written to any JSON frame or the transcript. | |||
| 15 | + | // | |||
| 16 | + | // Build: | |||
| 17 | + | // | |||
| 18 | + | // cd examples/extensions/secret | |||
| 19 | + | // go build -o secret . | |||
| 20 | + | // | |||
| 21 | + | // Install: | |||
| 22 | + | // | |||
| 23 | + | // zot ext install . | |||
| 24 | + | // | |||
| 25 | + | // Try it — ask zot something like: | |||
| 26 | + | // | |||
| 27 | + | // "Fetch https://internal.example.com/report — it needs a password." | |||
| 28 | + | // | |||
| 29 | + | // The model calls fetch_with_password; a masked password panel opens. | |||
| 30 | + | // Type anything and press Enter. The model receives the result without | |||
| 31 | + | // ever seeing what you typed. | |||
| 32 | + | package main | |||
| 33 | + | ||||
| 34 | + | import ( | |||
| 35 | + | "encoding/json" | |||
| 36 | + | "fmt" | |||
| 37 | + | "os" | |||
| 38 | + | "strings" | |||
| 39 | + | "sync" | |||
| 40 | + | ||||
| 41 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |||
| 42 | + | ) | |||
| 43 | + | ||||
| 44 | + | const schema = `{ | |||
| 45 | + | "type": "object", | |||
| 46 | + | "properties": { | |||
| 47 | + | "url": { | |||
| 48 | + | "type": "string", | |||
| 49 | + | "description": "The URL to fetch." | |||
| 50 | + | } | |||
| 51 | + | }, | |||
| 52 | + | "required": ["url"] | |||
| 53 | + | }` | |||
| 54 | + | ||||
| 55 | + | func main() { | |||
| 56 | + | e := ext.New("secret", "1.0.0") | |||
| 57 | + | ||||
| 58 | + | var mu sync.Mutex | |||
| 59 | + | var counter int | |||
| 60 | + | nextPID := func() string { | |||
| 61 | + | mu.Lock() | |||
| 62 | + | defer mu.Unlock() | |||
| 63 | + | counter++ | |||
| 64 | + | return fmt.Sprintf("secret-%d", counter) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | e.Tool("fetch_with_password", | |||
| 68 | + | "Fetch a URL that requires a password. The password is collected directly from the user and never exposed to the model.", | |||
| 69 | + | json.RawMessage(schema), | |||
| 70 | + | func(args json.RawMessage) ext.ToolResult { | |||
| 71 | + | var in struct { | |||
| 72 | + | URL string `json:"url"` | |||
| 73 | + | } | |||
| 74 | + | if err := json.Unmarshal(args, &in); err != nil { | |||
| 75 | + | return ext.TextErrorResult("invalid args: " + err.Error()) | |||
| 76 | + | } | |||
| 77 | + | if strings.TrimSpace(in.URL) == "" { | |||
| 78 | + | return ext.TextErrorResult("url is required") | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | pid := nextPID() | |||
| 82 | + | ||||
| 83 | + | type result struct { | |||
| 84 | + | password string | |||
| 85 | + | ok bool | |||
| 86 | + | } | |||
| 87 | + | ch := make(chan result, 1) | |||
| 88 | + | ||||
| 89 | + | // input and inputMu are owned by the key handler goroutine. | |||
| 90 | + | var inputMu sync.Mutex | |||
| 91 | + | var input string | |||
| 92 | + | ||||
| 93 | + | render := func() { | |||
| 94 | + | inputMu.Lock() | |||
| 95 | + | masked := strings.Repeat("●", len([]rune(input))) | |||
| 96 | + | inputMu.Unlock() | |||
| 97 | + | e.RenderPanel(pid, "Password required", | |||
| 98 | + | []string{ | |||
| 99 | + | " URL: " + in.URL, | |||
| 100 | + | "", | |||
| 101 | + | " Password: " + masked + "▌", | |||
| 102 | + | }, | |||
| 103 | + | "type password enter confirm esc cancel") | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | e.OnPanelKey(pid, func(key, text string) { | |||
| 107 | + | inputMu.Lock() | |||
| 108 | + | switch key { | |||
| 109 | + | case "rune": | |||
| 110 | + | input += text | |||
| 111 | + | inputMu.Unlock() | |||
| 112 | + | render() | |||
| 113 | + | return | |||
| 114 | + | case "backspace": | |||
| 115 | + | if len(input) > 0 { | |||
| 116 | + | r := []rune(input) | |||
| 117 | + | input = string(r[:len(r)-1]) | |||
| 118 | + | } | |||
| 119 | + | inputMu.Unlock() | |||
| 120 | + | render() | |||
| 121 | + | return | |||
| 122 | + | case "enter": | |||
| 123 | + | password := input | |||
| 124 | + | inputMu.Unlock() | |||
| 125 | + | e.ClosePanel(pid) | |||
| 126 | + | ch <- result{password: password, ok: true} | |||
| 127 | + | return | |||
| 128 | + | case "esc": | |||
| 129 | + | inputMu.Unlock() | |||
| 130 | + | e.ClosePanel(pid) | |||
| 131 | + | ch <- result{} | |||
| 132 | + | return | |||
| 133 | + | default: | |||
| 134 | + | inputMu.Unlock() | |||
| 135 | + | } | |||
| 136 | + | }, func() { | |||
| 137 | + | // Panel closed by host (user navigated away). | |||
| 138 | + | select { | |||
| 139 | + | case ch <- result{}: | |||
| 140 | + | default: | |||
| 141 | + | } | |||
| 142 | + | }) | |||
| 143 | + | ||||
| 144 | + | // Open the panel from inside the tool handler. | |||
| 145 | + | e.OpenPanel(pid, "Password required", | |||
| 146 | + | []string{ | |||
| 147 | + | " URL: " + in.URL, | |||
| 148 | + | "", | |||
| 149 | + | " Password: ▌", | |||
| 150 | + | }, | |||
| 151 | + | "type password enter confirm esc cancel") | |||
| 152 | + | ||||
| 153 | + | // Block until the user submits or cancels. | |||
| 154 | + | r := <-ch | |||
| 155 | + | if !r.ok { | |||
| 156 | + | return ext.TextErrorResult("cancelled: user did not provide a password") | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Use the password directly here. It never leaves this | |||
| 160 | + | // process and is never written to any frame or transcript. | |||
| 161 | + | return doFetch(in.URL, r.password) | |||
| 162 | + | }) | |||
| 163 | + | ||||
| 164 | + | if err := e.Run(); err != nil { | |||
| 165 | + | e.Logf("fatal: %v", err) | |||
| 166 | + | os.Exit(1) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // doFetch is a stub. Replace with a real http.Get + BasicAuth or | |||
| 171 | + | // token header in production use. | |||
| 172 | + | func doFetch(url, password string) ext.ToolResult { | |||
| 173 | + | if password == "" { | |||
| 174 | + | return ext.TextErrorResult("fetch failed: empty password") | |||
| 175 | + | } | |||
| 176 | + | // Demonstrate that we *have* the password without logging it. | |||
| 177 | + | return ext.TextResult(fmt.Sprintf( | |||
| 178 | + | "fetched %s successfully (password was %d characters, not shown)", | |||
| 179 | + | url, len([]rune(password)), | |||
| 180 | + | )) | |||
| 181 | + | } | |||
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"` | ||