Add tool confirmation lifecycle event
- commit
- 501b41bb63361928051458d7d27e27f224520490
- author
- patriceckhart · · 2026-07-28 17:46 UTC
- parent
- 1d11cc6
- signature
- unsigned
- refs
- tag: v0.3.18
7 files · +165 -20
M docs/extensions.md +10 -2
| @@ -246,12 +246,18 @@ which it wants to intercept. Send once after `hello`, before `ready`. | |||
| 246 | 246 | ||
| 247 | 247 | ```json | |
| 248 | 248 | {"type":"subscribe", | |
| 249 | - | "events":["session_start","turn_start","tool_call","turn_end","assistant_message"], | |
| 249 | + | "events":["session_start","turn_start","tool_call","tool_confirmation_requested","turn_end","assistant_message"], | |
| 250 | 250 | "intercept":["tool_call","turn_start","assistant_message"]} | |
| 251 | 251 | ``` | |
| 252 | 252 | ||
| 253 | 253 | Recognised event names: `session_start`, `turn_start`, `turn_end`, | |
| 254 | - | `tool_call`, `assistant_message`. | |
| 254 | + | `tool_call`, `tool_confirmation_requested`, `assistant_message`. | |
| 255 | + | ||
| 256 | + | `tool_confirmation_requested` fires only when zot is about to wait for | |
| 257 | + | interactive approval. Calls running in yolo mode, calls covered by a | |
| 258 | + | remembered approval, and calls blocked before confirmation do not emit it. | |
| 259 | + | The event includes `tool_id`, `tool_name`, and the short `tool_preview` | |
| 260 | + | shown in the confirmation dialog. | |
| 255 | 261 | ||
| 256 | 262 | Interceptable events: | |
| 257 | 263 | ||
| @@ -448,6 +454,8 @@ Lifecycle notification for events the extension subscribed to via | |||
| 448 | 454 | {"type":"event","event":"turn_start","step":1} | |
| 449 | 455 | {"type":"event","event":"tool_call", | |
| 450 | 456 | "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}} | |
| 457 | + | {"type":"event","event":"tool_confirmation_requested", | |
| 458 | + | "tool_id":"...","tool_name":"read","tool_preview":"foo.go"} | |
| 451 | 459 | {"type":"event","event":"turn_end","stop":"end_turn"} | |
| 452 | 460 | ``` | |
| 453 | 461 | ||
| @@ -246,12 +246,18 @@ which it wants to intercept. Send once after `hello`, before `ready`. | |||||
| 246 | 246 | ||||
| 247 | ```json | 247 | ```json | ||
| 248 | {"type":"subscribe", | 248 | {"type":"subscribe", | ||
| 249 | - | "events":["session_start","turn_start","tool_call","turn_end","assistant_message"], | 249 | + | "events":["session_start","turn_start","tool_call","tool_confirmation_requested","turn_end","assistant_message"], |
| 250 | "intercept":["tool_call","turn_start","assistant_message"]} | 250 | "intercept":["tool_call","turn_start","assistant_message"]} | ||
| 251 | ``` | 251 | ``` | ||
| 252 | 252 | ||||
| 253 | Recognised event names: `session_start`, `turn_start`, `turn_end`, | 253 | Recognised event names: `session_start`, `turn_start`, `turn_end`, | ||
| 254 | - | `tool_call`, `assistant_message`. | 254 | + | `tool_call`, `tool_confirmation_requested`, `assistant_message`. |
| 255 | + | ||||
| 256 | + | `tool_confirmation_requested` fires only when zot is about to wait for | |||
| 257 | + | interactive approval. Calls running in yolo mode, calls covered by a | |||
| 258 | + | remembered approval, and calls blocked before confirmation do not emit it. | |||
| 259 | + | The event includes `tool_id`, `tool_name`, and the short `tool_preview` | |||
| 260 | + | shown in the confirmation dialog. | |||
| 255 | 261 | ||||
| 256 | Interceptable events: | 262 | Interceptable events: | ||
| 257 | 263 | ||||
| @@ -448,6 +454,8 @@ Lifecycle notification for events the extension subscribed to via | |||||
| 448 | {"type":"event","event":"turn_start","step":1} | 454 | {"type":"event","event":"turn_start","step":1} | ||
| 449 | {"type":"event","event":"tool_call", | 455 | {"type":"event","event":"tool_call", | ||
| 450 | "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}} | 456 | "tool_id":"...","tool_name":"read","tool_args":{"path":"foo.go"}} | ||
| 457 | + | {"type":"event","event":"tool_confirmation_requested", | |||
| 458 | + | "tool_id":"...","tool_name":"read","tool_preview":"foo.go"} | |||
| 451 | {"type":"event","event":"turn_end","stop":"end_turn"} | 459 | {"type":"event","event":"turn_end","stop":"end_turn"} | ||
| 452 | ``` | 460 | ``` | ||
| 453 | 461 | ||||
M packages/agent/cli.go +4 -1
| @@ -1132,7 +1132,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1132 | 1132 | // (the BeforeToolExecute closure captures it). SetConfirmer | |
| 1133 | 1133 | // is mutex-guarded on the gate so this is safe. | |
| 1134 | 1134 | if confirmGate != nil { | |
| 1135 | - | confirmGate.SetConfirmer(iv) | |
| 1135 | + | confirmGate.SetConfirmer(&confirmationEventConfirmer{ | |
| 1136 | + | inner: iv, | |
| 1137 | + | emit: extMgr.EmitEvent, | |
| 1138 | + | }) | |
| 1136 | 1139 | } | |
| 1137 | 1140 | ||
| 1138 | 1141 | // Signal-driven flush: a SIGTERM / SIGHUP to the zot process | |
| @@ -1132,7 +1132,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1132 | // (the BeforeToolExecute closure captures it). SetConfirmer | 1132 | // (the BeforeToolExecute closure captures it). SetConfirmer | ||
| 1133 | // is mutex-guarded on the gate so this is safe. | 1133 | // is mutex-guarded on the gate so this is safe. | ||
| 1134 | if confirmGate != nil { | 1134 | if confirmGate != nil { | ||
| 1135 | - | confirmGate.SetConfirmer(iv) | 1135 | + | confirmGate.SetConfirmer(&confirmationEventConfirmer{ |
| 1136 | + | inner: iv, | |||
| 1137 | + | emit: extMgr.EmitEvent, | |||
| 1138 | + | }) | |||
| 1136 | } | 1139 | } | ||
| 1137 | 1140 | ||||
| 1138 | // Signal-driven flush: a SIGTERM / SIGHUP to the zot process | 1141 | // Signal-driven flush: a SIGTERM / SIGHUP to the zot process | ||
A packages/agent/confirmation_events.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 5 | + | "github.com/patriceckhart/zot/packages/core" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // confirmationEventConfirmer decorates an interactive confirmer with an | |
| 9 | + | // extension lifecycle event. ConfirmGate only invokes this decorator when a | |
| 10 | + | // call actually needs user input, after remembered approvals have been checked. | |
| 11 | + | type confirmationEventConfirmer struct { | |
| 12 | + | inner core.Confirmer | |
| 13 | + | emit func(extproto.EventFromHost) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (c *confirmationEventConfirmer) Confirm(toolName, preview string) core.ConfirmDecision { | |
| 17 | + | call := core.ToolCallConfirmation{Name: toolName, Summary: preview} | |
| 18 | + | c.emitRequest(call) | |
| 19 | + | return c.inner.Confirm(toolName, preview) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (c *confirmationEventConfirmer) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | |
| 23 | + | c.emitRequest(call) | |
| 24 | + | if detailed, ok := c.inner.(core.ToolCallConfirmer); ok { | |
| 25 | + | return detailed.ConfirmToolCall(call) | |
| 26 | + | } | |
| 27 | + | return c.inner.Confirm(call.Name, call.Summary) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (c *confirmationEventConfirmer) emitRequest(call core.ToolCallConfirmation) { | |
| 31 | + | if c.emit == nil { | |
| 32 | + | return | |
| 33 | + | } | |
| 34 | + | c.emit(extproto.EventFromHost{ | |
| 35 | + | Event: "tool_confirmation_requested", | |
| 36 | + | ToolID: call.ID, | |
| 37 | + | ToolName: call.Name, | |
| 38 | + | ToolPreview: call.Summary, | |
| 39 | + | }) | |
| 40 | + | } | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 5 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // confirmationEventConfirmer decorates an interactive confirmer with an | |||
| 9 | + | // extension lifecycle event. ConfirmGate only invokes this decorator when a | |||
| 10 | + | // call actually needs user input, after remembered approvals have been checked. | |||
| 11 | + | type confirmationEventConfirmer struct { | |||
| 12 | + | inner core.Confirmer | |||
| 13 | + | emit func(extproto.EventFromHost) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (c *confirmationEventConfirmer) Confirm(toolName, preview string) core.ConfirmDecision { | |||
| 17 | + | call := core.ToolCallConfirmation{Name: toolName, Summary: preview} | |||
| 18 | + | c.emitRequest(call) | |||
| 19 | + | return c.inner.Confirm(toolName, preview) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (c *confirmationEventConfirmer) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | |||
| 23 | + | c.emitRequest(call) | |||
| 24 | + | if detailed, ok := c.inner.(core.ToolCallConfirmer); ok { | |||
| 25 | + | return detailed.ConfirmToolCall(call) | |||
| 26 | + | } | |||
| 27 | + | return c.inner.Confirm(call.Name, call.Summary) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (c *confirmationEventConfirmer) emitRequest(call core.ToolCallConfirmation) { | |||
| 31 | + | if c.emit == nil { | |||
| 32 | + | return | |||
| 33 | + | } | |||
| 34 | + | c.emit(extproto.EventFromHost{ | |||
| 35 | + | Event: "tool_confirmation_requested", | |||
| 36 | + | ToolID: call.ID, | |||
| 37 | + | ToolName: call.Name, | |||
| 38 | + | ToolPreview: call.Summary, | |||
| 39 | + | }) | |||
| 40 | + | } | |||
A packages/agent/confirmation_events_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type rememberingDetailedConfirmer struct { | |
| 12 | + | calls []core.ToolCallConfirmation | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func (c *rememberingDetailedConfirmer) Confirm(string, string) core.ConfirmDecision { | |
| 16 | + | panic("Confirm called instead of ConfirmToolCall") | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (c *rememberingDetailedConfirmer) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | |
| 20 | + | c.calls = append(c.calls, call) | |
| 21 | + | return core.ConfirmDecision{Allow: true, RememberTool: true} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestConfirmationEventConfirmerEmitsOnlyWhenPrompting(t *testing.T) { | |
| 25 | + | inner := &rememberingDetailedConfirmer{} | |
| 26 | + | var events []extproto.EventFromHost | |
| 27 | + | notifier := &confirmationEventConfirmer{ | |
| 28 | + | inner: inner, | |
| 29 | + | emit: func(event extproto.EventFromHost) { | |
| 30 | + | events = append(events, event) | |
| 31 | + | }, | |
| 32 | + | } | |
| 33 | + | gate := core.NewConfirmGate(notifier) | |
| 34 | + | call := core.ToolCallConfirmation{ | |
| 35 | + | ID: "call-1", | |
| 36 | + | Name: "edit", | |
| 37 | + | Summary: "main.go", | |
| 38 | + | Content: "-old\n+new\n", | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | if allowed, _, _ := gate.CheckToolCall(call); !allowed { | |
| 42 | + | t.Fatal("first call should be allowed") | |
| 43 | + | } | |
| 44 | + | if allowed, _, _ := gate.CheckToolCall(core.ToolCallConfirmation{Name: "edit", Summary: "other.go"}); !allowed { | |
| 45 | + | t.Fatal("remembered tool should be allowed") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | if len(events) != 1 { | |
| 49 | + | t.Fatalf("events = %d, want 1", len(events)) | |
| 50 | + | } | |
| 51 | + | want := extproto.EventFromHost{ | |
| 52 | + | Event: "tool_confirmation_requested", | |
| 53 | + | ToolID: "call-1", | |
| 54 | + | ToolName: "edit", | |
| 55 | + | ToolPreview: "main.go", | |
| 56 | + | } | |
| 57 | + | if !reflect.DeepEqual(events[0], want) { | |
| 58 | + | t.Fatalf("event = %+v, want %+v", events[0], want) | |
| 59 | + | } | |
| 60 | + | if len(inner.calls) != 1 || inner.calls[0] != call { | |
| 61 | + | t.Fatalf("inner calls = %+v, want [%+v]", inner.calls, call) | |
| 62 | + | } | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type rememberingDetailedConfirmer struct { | |||
| 12 | + | calls []core.ToolCallConfirmation | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func (c *rememberingDetailedConfirmer) Confirm(string, string) core.ConfirmDecision { | |||
| 16 | + | panic("Confirm called instead of ConfirmToolCall") | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (c *rememberingDetailedConfirmer) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { | |||
| 20 | + | c.calls = append(c.calls, call) | |||
| 21 | + | return core.ConfirmDecision{Allow: true, RememberTool: true} | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestConfirmationEventConfirmerEmitsOnlyWhenPrompting(t *testing.T) { | |||
| 25 | + | inner := &rememberingDetailedConfirmer{} | |||
| 26 | + | var events []extproto.EventFromHost | |||
| 27 | + | notifier := &confirmationEventConfirmer{ | |||
| 28 | + | inner: inner, | |||
| 29 | + | emit: func(event extproto.EventFromHost) { | |||
| 30 | + | events = append(events, event) | |||
| 31 | + | }, | |||
| 32 | + | } | |||
| 33 | + | gate := core.NewConfirmGate(notifier) | |||
| 34 | + | call := core.ToolCallConfirmation{ | |||
| 35 | + | ID: "call-1", | |||
| 36 | + | Name: "edit", | |||
| 37 | + | Summary: "main.go", | |||
| 38 | + | Content: "-old\n+new\n", | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | if allowed, _, _ := gate.CheckToolCall(call); !allowed { | |||
| 42 | + | t.Fatal("first call should be allowed") | |||
| 43 | + | } | |||
| 44 | + | if allowed, _, _ := gate.CheckToolCall(core.ToolCallConfirmation{Name: "edit", Summary: "other.go"}); !allowed { | |||
| 45 | + | t.Fatal("remembered tool should be allowed") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | if len(events) != 1 { | |||
| 49 | + | t.Fatalf("events = %d, want 1", len(events)) | |||
| 50 | + | } | |||
| 51 | + | want := extproto.EventFromHost{ | |||
| 52 | + | Event: "tool_confirmation_requested", | |||
| 53 | + | ToolID: "call-1", | |||
| 54 | + | ToolName: "edit", | |||
| 55 | + | ToolPreview: "main.go", | |||
| 56 | + | } | |||
| 57 | + | if !reflect.DeepEqual(events[0], want) { | |||
| 58 | + | t.Fatalf("event = %+v, want %+v", events[0], want) | |||
| 59 | + | } | |||
| 60 | + | if len(inner.calls) != 1 || inner.calls[0] != call { | |||
| 61 | + | t.Fatalf("inner calls = %+v, want [%+v]", inner.calls, call) | |||
| 62 | + | } | |||
| 63 | + | } | |||
M packages/agent/ext/ext.go +10 -8
| @@ -68,9 +68,10 @@ type EventHandler func(ev Event) | |||
| 68 | 68 | // | |
| 69 | 69 | // session_start : (no extra fields) | |
| 70 | 70 | // turn_start : Step | |
| 71 | - | // turn_end : Stop, optional Error | |
| 72 | - | // tool_call : ToolID, ToolName, ToolArgs | |
| 73 | - | // assistant_message: Text | |
| 71 | + | // turn_end : Stop, optional Error | |
| 72 | + | // tool_call : ToolID, ToolName, ToolArgs | |
| 73 | + | // tool_confirmation_requested: ToolID, ToolName, ToolPreview | |
| 74 | + | // assistant_message : Text | |
| 74 | 75 | type Event struct { | |
| 75 | 76 | Name string | |
| 76 | 77 | ||
| @@ -78,9 +79,10 @@ type Event struct { | |||
| 78 | 79 | Stop string | |
| 79 | 80 | Error string | |
| 80 | 81 | ||
| 81 | - | ToolID string | |
| 82 | - | ToolName string | |
| 83 | - | ToolArgs json.RawMessage | |
| 82 | + | ToolID string | |
| 83 | + | ToolName string | |
| 84 | + | ToolArgs json.RawMessage | |
| 85 | + | ToolPreview string | |
| 84 | 86 | ||
| 85 | 87 | Text string | |
| 86 | 88 | } | |
| @@ -410,7 +412,7 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag | |||
| 410 | 412 | // notification; the same name can only have one handler (later | |
| 411 | 413 | // registrations replace earlier ones). Recognised names: | |
| 412 | 414 | // session_start, turn_start, turn_end, tool_call, | |
| 413 | - | // assistant_message. | |
| 415 | + | // tool_confirmation_requested, assistant_message. | |
| 414 | 416 | func (e *Extension) On(name string, fn EventHandler) { | |
| 415 | 417 | e.mu.Lock() | |
| 416 | 418 | if _, exists := e.eventHandlers[name]; !exists { | |
| @@ -642,7 +644,7 @@ func (e *Extension) Run() error { | |||
| 642 | 644 | handler(Event{ | |
| 643 | 645 | Name: ef.Event, Step: ef.Step, Stop: ef.Stop, | |
| 644 | 646 | Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName, | |
| 645 | - | ToolArgs: ef.ToolArgs, Text: ef.Text, | |
| 647 | + | ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text, | |
| 646 | 648 | }) | |
| 647 | 649 | }() | |
| 648 | 650 | } | |
| @@ -68,9 +68,10 @@ type EventHandler func(ev Event) | |||||
| 68 | // | 68 | // | ||
| 69 | // session_start : (no extra fields) | 69 | // session_start : (no extra fields) | ||
| 70 | // turn_start : Step | 70 | // turn_start : Step | ||
| 71 | - | // turn_end : Stop, optional Error | 71 | + | // turn_end : Stop, optional Error |
| 72 | - | // tool_call : ToolID, ToolName, ToolArgs | 72 | + | // tool_call : ToolID, ToolName, ToolArgs |
| 73 | - | // assistant_message: Text | 73 | + | // tool_confirmation_requested: ToolID, ToolName, ToolPreview |
| 74 | + | // assistant_message : Text | |||
| 74 | type Event struct { | 75 | type Event struct { | ||
| 75 | Name string | 76 | Name string | ||
| 76 | 77 | ||||
| @@ -78,9 +79,10 @@ type Event struct { | |||||
| 78 | Stop string | 79 | Stop string | ||
| 79 | Error string | 80 | Error string | ||
| 80 | 81 | ||||
| 81 | - | ToolID string | 82 | + | ToolID string |
| 82 | - | ToolName string | 83 | + | ToolName string |
| 83 | - | ToolArgs json.RawMessage | 84 | + | ToolArgs json.RawMessage |
| 85 | + | ToolPreview string | |||
| 84 | 86 | ||||
| 85 | Text string | 87 | Text string | ||
| 86 | } | 88 | } | ||
| @@ -410,7 +412,7 @@ func (e *Extension) registerTool(name, description string, schema json.RawMessag | |||||
| 410 | // notification; the same name can only have one handler (later | 412 | // notification; the same name can only have one handler (later | ||
| 411 | // registrations replace earlier ones). Recognised names: | 413 | // registrations replace earlier ones). Recognised names: | ||
| 412 | // session_start, turn_start, turn_end, tool_call, | 414 | // session_start, turn_start, turn_end, tool_call, | ||
| 413 | - | // assistant_message. | 415 | + | // tool_confirmation_requested, assistant_message. |
| 414 | func (e *Extension) On(name string, fn EventHandler) { | 416 | func (e *Extension) On(name string, fn EventHandler) { | ||
| 415 | e.mu.Lock() | 417 | e.mu.Lock() | ||
| 416 | if _, exists := e.eventHandlers[name]; !exists { | 418 | if _, exists := e.eventHandlers[name]; !exists { | ||
| @@ -642,7 +644,7 @@ func (e *Extension) Run() error { | |||||
| 642 | handler(Event{ | 644 | handler(Event{ | ||
| 643 | Name: ef.Event, Step: ef.Step, Stop: ef.Stop, | 645 | Name: ef.Event, Step: ef.Step, Stop: ef.Stop, | ||
| 644 | Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName, | 646 | Error: ef.Error, ToolID: ef.ToolID, ToolName: ef.ToolName, | ||
| 645 | - | ToolArgs: ef.ToolArgs, Text: ef.Text, | 647 | + | ToolArgs: ef.ToolArgs, ToolPreview: ef.ToolPreview, Text: ef.Text, |
| 646 | }) | 648 | }) | ||
| 647 | }() | 649 | }() | ||
| 648 | } | 650 | } | ||
M packages/agent/ext/ext_test.go +28 -0
| @@ -220,6 +220,34 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) { | |||
| 220 | 220 | h.hostW.Close() | |
| 221 | 221 | } | |
| 222 | 222 | ||
| 223 | + | func TestToolConfirmationRequestedEvent(t *testing.T) { | |
| 224 | + | h := newHarness("confirmation-events") | |
| 225 | + | received := make(chan Event, 1) | |
| 226 | + | h.ext.On("tool_confirmation_requested", func(event Event) { | |
| 227 | + | received <- event | |
| 228 | + | }) | |
| 229 | + | ||
| 230 | + | go h.ext.Run() | |
| 231 | + | h.handshake(t) | |
| 232 | + | h.sendToExt(t, extproto.EventFromHost{ | |
| 233 | + | Type: "event", | |
| 234 | + | Event: "tool_confirmation_requested", | |
| 235 | + | ToolID: "call-1", | |
| 236 | + | ToolName: "bash", | |
| 237 | + | ToolPreview: "go test ./...", | |
| 238 | + | }) | |
| 239 | + | ||
| 240 | + | select { | |
| 241 | + | case event := <-received: | |
| 242 | + | if event.Name != "tool_confirmation_requested" || event.ToolID != "call-1" || event.ToolName != "bash" || event.ToolPreview != "go test ./..." { | |
| 243 | + | t.Fatalf("event = %+v", event) | |
| 244 | + | } | |
| 245 | + | case <-time.After(2 * time.Second): | |
| 246 | + | t.Fatal("timeout waiting for confirmation event") | |
| 247 | + | } | |
| 248 | + | h.hostW.Close() | |
| 249 | + | } | |
| 250 | + | ||
| 223 | 251 | func TestDeferredToolRegistrationAndActivation(t *testing.T) { | |
| 224 | 252 | h := newHarness("deferred-ext") | |
| 225 | 253 | h.ext.DeferredTool("weather", "weather lookup", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult { | |
| @@ -220,6 +220,34 @@ func TestOpenPanelEmitsCorrectFrame(t *testing.T) { | |||||
| 220 | h.hostW.Close() | 220 | h.hostW.Close() | ||
| 221 | } | 221 | } | ||
| 222 | 222 | ||||
| 223 | + | func TestToolConfirmationRequestedEvent(t *testing.T) { | |||
| 224 | + | h := newHarness("confirmation-events") | |||
| 225 | + | received := make(chan Event, 1) | |||
| 226 | + | h.ext.On("tool_confirmation_requested", func(event Event) { | |||
| 227 | + | received <- event | |||
| 228 | + | }) | |||
| 229 | + | ||||
| 230 | + | go h.ext.Run() | |||
| 231 | + | h.handshake(t) | |||
| 232 | + | h.sendToExt(t, extproto.EventFromHost{ | |||
| 233 | + | Type: "event", | |||
| 234 | + | Event: "tool_confirmation_requested", | |||
| 235 | + | ToolID: "call-1", | |||
| 236 | + | ToolName: "bash", | |||
| 237 | + | ToolPreview: "go test ./...", | |||
| 238 | + | }) | |||
| 239 | + | ||||
| 240 | + | select { | |||
| 241 | + | case event := <-received: | |||
| 242 | + | if event.Name != "tool_confirmation_requested" || event.ToolID != "call-1" || event.ToolName != "bash" || event.ToolPreview != "go test ./..." { | |||
| 243 | + | t.Fatalf("event = %+v", event) | |||
| 244 | + | } | |||
| 245 | + | case <-time.After(2 * time.Second): | |||
| 246 | + | t.Fatal("timeout waiting for confirmation event") | |||
| 247 | + | } | |||
| 248 | + | h.hostW.Close() | |||
| 249 | + | } | |||
| 250 | + | ||||
| 223 | func TestDeferredToolRegistrationAndActivation(t *testing.T) { | 251 | func TestDeferredToolRegistrationAndActivation(t *testing.T) { | ||
| 224 | h := newHarness("deferred-ext") | 252 | h := newHarness("deferred-ext") | ||
| 225 | h.ext.DeferredTool("weather", "weather lookup", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult { | 253 | h.ext.DeferredTool("weather", "weather lookup", json.RawMessage(`{"type":"object"}`), func(json.RawMessage) ToolResult { | ||
M packages/agent/extproto/extproto.go +10 -9
| @@ -183,15 +183,16 @@ type ToolCallFromHost struct { | |||
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | 185 | type EventFromHost struct { | |
| 186 | - | Type string `json:"type"` | |
| 187 | - | Event string `json:"event"` | |
| 188 | - | Step int `json:"step,omitempty"` | |
| 189 | - | Stop string `json:"stop,omitempty"` | |
| 190 | - | Error string `json:"error,omitempty"` | |
| 191 | - | ToolID string `json:"tool_id,omitempty"` | |
| 192 | - | ToolName string `json:"tool_name,omitempty"` | |
| 193 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 194 | - | Text string `json:"text,omitempty"` | |
| 186 | + | Type string `json:"type"` | |
| 187 | + | Event string `json:"event"` | |
| 188 | + | Step int `json:"step,omitempty"` | |
| 189 | + | Stop string `json:"stop,omitempty"` | |
| 190 | + | Error string `json:"error,omitempty"` | |
| 191 | + | ToolID string `json:"tool_id,omitempty"` | |
| 192 | + | ToolName string `json:"tool_name,omitempty"` | |
| 193 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 194 | + | ToolPreview string `json:"tool_preview,omitempty"` | |
| 195 | + | Text string `json:"text,omitempty"` | |
| 195 | 196 | } | |
| 196 | 197 | ||
| 197 | 198 | type EventInterceptFromHost struct { | |
| @@ -183,15 +183,16 @@ type ToolCallFromHost struct { | |||||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | type EventFromHost struct { | 185 | type EventFromHost struct { | ||
| 186 | - | Type string `json:"type"` | 186 | + | Type string `json:"type"` |
| 187 | - | Event string `json:"event"` | 187 | + | Event string `json:"event"` |
| 188 | - | Step int `json:"step,omitempty"` | 188 | + | Step int `json:"step,omitempty"` |
| 189 | - | Stop string `json:"stop,omitempty"` | 189 | + | Stop string `json:"stop,omitempty"` |
| 190 | - | Error string `json:"error,omitempty"` | 190 | + | Error string `json:"error,omitempty"` |
| 191 | - | ToolID string `json:"tool_id,omitempty"` | 191 | + | ToolID string `json:"tool_id,omitempty"` |
| 192 | - | ToolName string `json:"tool_name,omitempty"` | 192 | + | ToolName string `json:"tool_name,omitempty"` |
| 193 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | 193 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` |
| 194 | - | Text string `json:"text,omitempty"` | 194 | + | ToolPreview string `json:"tool_preview,omitempty"` |
| 195 | + | Text string `json:"text,omitempty"` | |||
| 195 | } | 196 | } | ||
| 196 | 197 | ||||
| 197 | type EventInterceptFromHost struct { | 198 | type EventInterceptFromHost struct { | ||