Add extension submit frame
- commit
- 8b354163d969e10170ce5c8e674d2b737280720b
- author
- patriceckhart · · 2026-07-03 18:48 UTC
- parent
- be0642f
- signature
- unsigned
5 files · +113 -10
M docs/extensions.md +14 -0
| @@ -316,6 +316,20 @@ Example: | |||
| 316 | 316 | If `error` is non-empty, zot renders it as a red status line | |
| 317 | 317 | regardless of `action`. | |
| 318 | 318 | ||
| 319 | + | #### `submit` (one-way, any time) | |
| 320 | + | ||
| 321 | + | Submits text as a user prompt in the interactive host. If the agent is | |
| 322 | + | idle, zot starts a turn immediately. If a turn is already running, zot | |
| 323 | + | queues the prompt behind it using the same queue path as typed input. | |
| 324 | + | Empty or whitespace-only text is ignored. | |
| 325 | + | ||
| 326 | + | ```json | |
| 327 | + | {"type":"submit","text":"Summarize the selected workspace."} | |
| 328 | + | ``` | |
| 329 | + | ||
| 330 | + | In print / JSON / RPC modes this frame is ignored because there is no | |
| 331 | + | interactive editor or prompt queue. | |
| 332 | + | ||
| 319 | 333 | #### `panel_render` (one-way, while a panel is open) | |
| 320 | 334 | ||
| 321 | 335 | Pushes a fresh frame for an already-open panel. | |
| @@ -316,6 +316,20 @@ Example: | |||||
| 316 | If `error` is non-empty, zot renders it as a red status line | 316 | If `error` is non-empty, zot renders it as a red status line | ||
| 317 | regardless of `action`. | 317 | regardless of `action`. | ||
| 318 | 318 | ||||
| 319 | + | #### `submit` (one-way, any time) | |||
| 320 | + | ||||
| 321 | + | Submits text as a user prompt in the interactive host. If the agent is | |||
| 322 | + | idle, zot starts a turn immediately. If a turn is already running, zot | |||
| 323 | + | queues the prompt behind it using the same queue path as typed input. | |||
| 324 | + | Empty or whitespace-only text is ignored. | |||
| 325 | + | ||||
| 326 | + | ```json | |||
| 327 | + | {"type":"submit","text":"Summarize the selected workspace."} | |||
| 328 | + | ``` | |||
| 329 | + | ||||
| 330 | + | In print / JSON / RPC modes this frame is ignored because there is no | |||
| 331 | + | interactive editor or prompt queue. | |||
| 332 | + | ||||
| 319 | #### `panel_render` (one-way, while a panel is open) | 333 | #### `panel_render` (one-way, while a panel is open) | ||
| 320 | 334 | ||||
| 321 | Pushes a fresh frame for an already-open panel. | 335 | Pushes a fresh frame for an already-open panel. | ||
M packages/agent/cli.go +1 -1
| @@ -49,7 +49,7 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) { | |||
| 49 | 49 | } | |
| 50 | 50 | func (h *interactiveExtHooks) Submit(text string) { | |
| 51 | 51 | if iv := h.iv(); iv != nil { | |
| 52 | - | iv.Submit(text) | |
| 52 | + | iv.SubmitOrQueue(text, nil) | |
| 53 | 53 | } | |
| 54 | 54 | } | |
| 55 | 55 | func (h *interactiveExtHooks) SubmitSlash(text string) { | |
| @@ -49,7 +49,7 @@ func (h *interactiveExtHooks) Notify(extName, level, message string) { | |||||
| 49 | } | 49 | } | ||
| 50 | func (h *interactiveExtHooks) Submit(text string) { | 50 | func (h *interactiveExtHooks) Submit(text string) { | ||
| 51 | if iv := h.iv(); iv != nil { | 51 | if iv := h.iv(); iv != nil { | ||
| 52 | - | iv.Submit(text) | 52 | + | iv.SubmitOrQueue(text, nil) |
| 53 | } | 53 | } | ||
| 54 | } | 54 | } | ||
| 55 | func (h *interactiveExtHooks) SubmitSlash(text string) { | 55 | func (h *interactiveExtHooks) SubmitSlash(text string) { | ||
M packages/agent/extensions/manager.go +8 -0
| @@ -781,6 +781,14 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||
| 781 | 781 | } | |
| 782 | 782 | case "clear_notes": | |
| 783 | 783 | m.hooks.ClearNotes(ext.Manifest.Name) | |
| 784 | + | case "submit": | |
| 785 | + | var s extproto.SubmitFromExt | |
| 786 | + | if err := json.Unmarshal(line, &s); err == nil { | |
| 787 | + | text := strings.TrimSpace(s.Text) | |
| 788 | + | if text != "" { | |
| 789 | + | m.hooks.Submit(text) | |
| 790 | + | } | |
| 791 | + | } | |
| 784 | 792 | case "submit_slash": | |
| 785 | 793 | // Spontaneous request to invoke a slash command in the | |
| 786 | 794 | // TUI. Refused unless the payload looks like a slash | |
| @@ -781,6 +781,14 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) { | |||||
| 781 | } | 781 | } | ||
| 782 | case "clear_notes": | 782 | case "clear_notes": | ||
| 783 | m.hooks.ClearNotes(ext.Manifest.Name) | 783 | m.hooks.ClearNotes(ext.Manifest.Name) | ||
| 784 | + | case "submit": | |||
| 785 | + | var s extproto.SubmitFromExt | |||
| 786 | + | if err := json.Unmarshal(line, &s); err == nil { | |||
| 787 | + | text := strings.TrimSpace(s.Text) | |||
| 788 | + | if text != "" { | |||
| 789 | + | m.hooks.Submit(text) | |||
| 790 | + | } | |||
| 791 | + | } | |||
| 784 | case "submit_slash": | 792 | case "submit_slash": | ||
| 785 | // Spontaneous request to invoke a slash command in the | 793 | // Spontaneous request to invoke a slash command in the | ||
| 786 | // TUI. Refused unless the payload looks like a slash | 794 | // TUI. Refused unless the payload looks like a slash | ||
M packages/agent/extensions/manager_test.go +82 -9
| @@ -16,12 +16,14 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | // stubHooks records every callback so the test can assert on them. | |
| 18 | 18 | type stubHooks struct { | |
| 19 | - | mu sync.Mutex | |
| 20 | - | notifies []string | |
| 21 | - | displays []string | |
| 22 | - | clearNotes []string | |
| 23 | - | panels []extproto.PanelSpec | |
| 24 | - | panelExts []string | |
| 19 | + | mu sync.Mutex | |
| 20 | + | notifies []string | |
| 21 | + | displays []string | |
| 22 | + | submits []string | |
| 23 | + | submitSlash []string | |
| 24 | + | clearNotes []string | |
| 25 | + | panels []extproto.PanelSpec | |
| 26 | + | panelExts []string | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | func (s *stubHooks) Notify(name, level, message string) { | |
| @@ -29,9 +31,17 @@ func (s *stubHooks) Notify(name, level, message string) { | |||
| 29 | 31 | defer s.mu.Unlock() | |
| 30 | 32 | s.notifies = append(s.notifies, name+":"+level+":"+message) | |
| 31 | 33 | } | |
| 32 | - | func (s *stubHooks) Submit(string) {} | |
| 33 | - | func (s *stubHooks) SubmitSlash(string) {} | |
| 34 | - | func (s *stubHooks) Insert(string) {} | |
| 34 | + | func (s *stubHooks) Submit(text string) { | |
| 35 | + | s.mu.Lock() | |
| 36 | + | defer s.mu.Unlock() | |
| 37 | + | s.submits = append(s.submits, text) | |
| 38 | + | } | |
| 39 | + | func (s *stubHooks) SubmitSlash(text string) { | |
| 40 | + | s.mu.Lock() | |
| 41 | + | defer s.mu.Unlock() | |
| 42 | + | s.submitSlash = append(s.submitSlash, text) | |
| 43 | + | } | |
| 44 | + | func (s *stubHooks) Insert(string) {} | |
| 35 | 45 | func (s *stubHooks) Display(name, text string) { | |
| 36 | 46 | s.mu.Lock() | |
| 37 | 47 | defer s.mu.Unlock() | |
| @@ -264,6 +274,69 @@ done | |||
| 264 | 274 | } | |
| 265 | 275 | } | |
| 266 | 276 | ||
| 277 | + | // TestSpontaneousSubmit verifies that an extension can submit a | |
| 278 | + | // non-empty prompt outside of any command response. | |
| 279 | + | func TestSpontaneousSubmit(t *testing.T) { | |
| 280 | + | if runtime.GOOS == "windows" { | |
| 281 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | tmp := t.TempDir() | |
| 285 | + | extDir := filepath.Join(tmp, "extensions", "submit-mock") | |
| 286 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |
| 287 | + | t.Fatal(err) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | script := `#!/bin/sh | |
| 291 | + | printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}' | |
| 292 | + | printf '%s\n' '{"type":"ready"}' | |
| 293 | + | printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}' | |
| 294 | + | printf '%s\n' '{"type":"submit","text":" "}' | |
| 295 | + | while IFS= read -r line; do | |
| 296 | + | case "$line" in | |
| 297 | + | *'"type":"shutdown"'*) | |
| 298 | + | printf '%s\n' '{"type":"shutdown_ack"}' | |
| 299 | + | exit 0 | |
| 300 | + | ;; | |
| 301 | + | esac | |
| 302 | + | done | |
| 303 | + | ` | |
| 304 | + | if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { | |
| 305 | + | t.Fatal(err) | |
| 306 | + | } | |
| 307 | + | mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"}) | |
| 308 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil { | |
| 309 | + | t.Fatal(err) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | hooks := &stubHooks{} | |
| 313 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks) | |
| 314 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |
| 315 | + | t.Fatalf("discover errors: %v", errs) | |
| 316 | + | } | |
| 317 | + | defer mgr.Stop(2 * time.Second) | |
| 318 | + | ||
| 319 | + | deadline := time.Now().Add(2 * time.Second) | |
| 320 | + | for time.Now().Before(deadline) { | |
| 321 | + | hooks.mu.Lock() | |
| 322 | + | n := len(hooks.submits) | |
| 323 | + | hooks.mu.Unlock() | |
| 324 | + | if n > 0 { | |
| 325 | + | break | |
| 326 | + | } | |
| 327 | + | time.Sleep(20 * time.Millisecond) | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | hooks.mu.Lock() | |
| 331 | + | defer hooks.mu.Unlock() | |
| 332 | + | if len(hooks.submits) != 1 { | |
| 333 | + | t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits) | |
| 334 | + | } | |
| 335 | + | if hooks.submits[0] != "explain this repository briefly" { | |
| 336 | + | t.Fatalf("Submit text = %q", hooks.submits[0]) | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 267 | 340 | // TestSpontaneousOpenPanel verifies that an extension sending an | |
| 268 | 341 | // open_panel frame outside of any command response causes the manager | |
| 269 | 342 | // to call hooks.OpenPanel with the correct PanelSpec fields. | |
| @@ -16,12 +16,14 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | // stubHooks records every callback so the test can assert on them. | 17 | // stubHooks records every callback so the test can assert on them. | ||
| 18 | type stubHooks struct { | 18 | type stubHooks struct { | ||
| 19 | - | mu sync.Mutex | 19 | + | mu sync.Mutex |
| 20 | - | notifies []string | 20 | + | notifies []string |
| 21 | - | displays []string | 21 | + | displays []string |
| 22 | - | clearNotes []string | 22 | + | submits []string |
| 23 | - | panels []extproto.PanelSpec | 23 | + | submitSlash []string |
| 24 | - | panelExts []string | 24 | + | clearNotes []string |
| 25 | + | panels []extproto.PanelSpec | |||
| 26 | + | panelExts []string | |||
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 27 | func (s *stubHooks) Notify(name, level, message string) { | 29 | func (s *stubHooks) Notify(name, level, message string) { | ||
| @@ -29,9 +31,17 @@ func (s *stubHooks) Notify(name, level, message string) { | |||||
| 29 | defer s.mu.Unlock() | 31 | defer s.mu.Unlock() | ||
| 30 | s.notifies = append(s.notifies, name+":"+level+":"+message) | 32 | s.notifies = append(s.notifies, name+":"+level+":"+message) | ||
| 31 | } | 33 | } | ||
| 32 | - | func (s *stubHooks) Submit(string) {} | 34 | + | func (s *stubHooks) Submit(text string) { |
| 33 | - | func (s *stubHooks) SubmitSlash(string) {} | 35 | + | s.mu.Lock() |
| 34 | - | func (s *stubHooks) Insert(string) {} | 36 | + | defer s.mu.Unlock() |
| 37 | + | s.submits = append(s.submits, text) | |||
| 38 | + | } | |||
| 39 | + | func (s *stubHooks) SubmitSlash(text string) { | |||
| 40 | + | s.mu.Lock() | |||
| 41 | + | defer s.mu.Unlock() | |||
| 42 | + | s.submitSlash = append(s.submitSlash, text) | |||
| 43 | + | } | |||
| 44 | + | func (s *stubHooks) Insert(string) {} | |||
| 35 | func (s *stubHooks) Display(name, text string) { | 45 | func (s *stubHooks) Display(name, text string) { | ||
| 36 | s.mu.Lock() | 46 | s.mu.Lock() | ||
| 37 | defer s.mu.Unlock() | 47 | defer s.mu.Unlock() | ||
| @@ -264,6 +274,69 @@ done | |||||
| 264 | } | 274 | } | ||
| 265 | } | 275 | } | ||
| 266 | 276 | ||||
| 277 | + | // TestSpontaneousSubmit verifies that an extension can submit a | |||
| 278 | + | // non-empty prompt outside of any command response. | |||
| 279 | + | func TestSpontaneousSubmit(t *testing.T) { | |||
| 280 | + | if runtime.GOOS == "windows" { | |||
| 281 | + | t.Skip("mock extension uses /bin/sh; skip on windows") | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | tmp := t.TempDir() | |||
| 285 | + | extDir := filepath.Join(tmp, "extensions", "submit-mock") | |||
| 286 | + | if err := os.MkdirAll(extDir, 0o755); err != nil { | |||
| 287 | + | t.Fatal(err) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | script := `#!/bin/sh | |||
| 291 | + | printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}' | |||
| 292 | + | printf '%s\n' '{"type":"ready"}' | |||
| 293 | + | printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}' | |||
| 294 | + | printf '%s\n' '{"type":"submit","text":" "}' | |||
| 295 | + | while IFS= read -r line; do | |||
| 296 | + | case "$line" in | |||
| 297 | + | *'"type":"shutdown"'*) | |||
| 298 | + | printf '%s\n' '{"type":"shutdown_ack"}' | |||
| 299 | + | exit 0 | |||
| 300 | + | ;; | |||
| 301 | + | esac | |||
| 302 | + | done | |||
| 303 | + | ` | |||
| 304 | + | if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil { | |||
| 305 | + | t.Fatal(err) | |||
| 306 | + | } | |||
| 307 | + | mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"}) | |||
| 308 | + | if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil { | |||
| 309 | + | t.Fatal(err) | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | hooks := &stubHooks{} | |||
| 313 | + | mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks) | |||
| 314 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |||
| 315 | + | t.Fatalf("discover errors: %v", errs) | |||
| 316 | + | } | |||
| 317 | + | defer mgr.Stop(2 * time.Second) | |||
| 318 | + | ||||
| 319 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 320 | + | for time.Now().Before(deadline) { | |||
| 321 | + | hooks.mu.Lock() | |||
| 322 | + | n := len(hooks.submits) | |||
| 323 | + | hooks.mu.Unlock() | |||
| 324 | + | if n > 0 { | |||
| 325 | + | break | |||
| 326 | + | } | |||
| 327 | + | time.Sleep(20 * time.Millisecond) | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | hooks.mu.Lock() | |||
| 331 | + | defer hooks.mu.Unlock() | |||
| 332 | + | if len(hooks.submits) != 1 { | |||
| 333 | + | t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits) | |||
| 334 | + | } | |||
| 335 | + | if hooks.submits[0] != "explain this repository briefly" { | |||
| 336 | + | t.Fatalf("Submit text = %q", hooks.submits[0]) | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | ||||
| 267 | // TestSpontaneousOpenPanel verifies that an extension sending an | 340 | // TestSpontaneousOpenPanel verifies that an extension sending an | ||
| 268 | // open_panel frame outside of any command response causes the manager | 341 | // open_panel frame outside of any command response causes the manager | ||
| 269 | // to call hooks.OpenPanel with the correct PanelSpec fields. | 342 | // to call hooks.OpenPanel with the correct PanelSpec fields. | ||
M packages/agent/extproto/extproto.go +8 -0
| @@ -143,6 +143,14 @@ type SubmitSlashFromExt struct { | |||
| 143 | 143 | Text string `json:"text"` | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | + | // SubmitFromExt is a spontaneous frame an extension can send to queue | |
| 147 | + | // a model prompt in the interactive host. The manager routes it through | |
| 148 | + | // HostHooks.Submit, so the TUI handles busy state and turn startup. | |
| 149 | + | type SubmitFromExt struct { | |
| 150 | + | Type string `json:"type"` | |
| 151 | + | Text string `json:"text"` | |
| 152 | + | } | |
| 153 | + | ||
| 146 | 154 | type ShutdownAckFromExt struct { | |
| 147 | 155 | Type string `json:"type"` | |
| 148 | 156 | } | |
| @@ -143,6 +143,14 @@ type SubmitSlashFromExt struct { | |||||
| 143 | Text string `json:"text"` | 143 | Text string `json:"text"` | ||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | + | // SubmitFromExt is a spontaneous frame an extension can send to queue | |||
| 147 | + | // a model prompt in the interactive host. The manager routes it through | |||
| 148 | + | // HostHooks.Submit, so the TUI handles busy state and turn startup. | |||
| 149 | + | type SubmitFromExt struct { | |||
| 150 | + | Type string `json:"type"` | |||
| 151 | + | Text string `json:"text"` | |||
| 152 | + | } | |||
| 153 | + | ||||
| 146 | type ShutdownAckFromExt struct { | 154 | type ShutdownAckFromExt struct { | ||
| 147 | Type string `json:"type"` | 155 | Type string `json:"type"` | ||
| 148 | } | 156 | } | ||