Add model-visible tool prompts for extension commands
- commit
- c5883a421a5209b0b8a8524ef2073fb307210c62
- author
- patriceckhart · · 2026-09-25 05:49 UTC
- parent
- beceda1
- signature
- unsigned
- refs
- tag: v0.3.96
10 files · +625 -11
M docs/extensions.md +31 -0
| @@ -426,6 +426,16 @@ chain. `before_agent_start` follows the non-blocking replacement rules above. | |||
| 426 | 426 | ||
| 427 | 427 | - `"prompt"` — submits `prompt` as a fresh user message; the agent | |
| 428 | 428 | runs a turn against it. | |
| 429 | + | - `"tool_prompt"`: submits `prompt`, executes `tool_name` with JSON-object | |
| 430 | + | `tool_args` through the active agent's normal tool guards and confirmation, | |
| 431 | + | then records a paired assistant tool call and tool result before the model | |
| 432 | + | turn. The full tool output is stored in the session and visible to the | |
| 433 | + | model, so extensions should only request this for content intended to be | |
| 434 | + | persistent model context. Tool errors are recorded as tool errors. | |
| 435 | + | Cancellation stops before the model turn and preserves any completed | |
| 436 | + | call/result pair. This action is supported for interactive slash commands, | |
| 437 | + | not spontaneous `call_tool` requests. The extension does not receive the | |
| 438 | + | tool result in the response. | |
| 429 | 439 | - `"insert"` — inserts `insert` into the editor at the cursor without | |
| 430 | 440 | submitting. | |
| 431 | 441 | - `"display"` — appends `display` to the chat as a one-shot styled | |
| @@ -436,6 +446,27 @@ chain. `before_agent_start` follows the non-blocking replacement rules above. | |||
| 436 | 446 | `notify` frames or kicked off background work). zot doesn't change | |
| 437 | 447 | the UI in response. | |
| 438 | 448 | ||
| 449 | + | For a skill shortcut, return the request without copying the skill output | |
| 450 | + | into the prompt: | |
| 451 | + | ||
| 452 | + | ```json | |
| 453 | + | {"type":"command_response","id":"...","action":"tool_prompt", | |
| 454 | + | "prompt":"and push","tool_name":"skill", | |
| 455 | + | "tool_args":{"name":"developer:commit"}} | |
| 456 | + | ``` | |
| 457 | + | ||
| 458 | + | Go SDK: `return ext.ToolPrompt("skill", map[string]string{"name": "developer:commit"}, args)`. | |
| 459 | + | Unlike `CallTool`, this executes after the command handler returns. Avoid | |
| 460 | + | calling the same tool in the handler or the work will run twice. The recorded | |
| 461 | + | assistant call includes `origin_extension` metadata. Older hosts do not | |
| 462 | + | understand the new action and report an unknown action instead of running it. | |
| 463 | + | For Gemini 3, zot adds Google's documented | |
| 464 | + | [`skip_thought_signature_validator`](https://ai.google.dev/gemini-api/docs/generate-content/thought-signatures) | |
| 465 | + | placeholder to unsigned synthetic function calls when serializing requests. | |
| 466 | + | Provider-issued thought signatures are preserved. The extension-to-Gemini | |
| 467 | + | request and session replay are covered with a local Gemini-compatible server, | |
| 468 | + | not a live Gemini API round trip. | |
| 469 | + | ||
| 439 | 470 | Example: | |
| 440 | 471 | ||
| 441 | 472 | ```json | |
| @@ -426,6 +426,16 @@ chain. `before_agent_start` follows the non-blocking replacement rules above. | |||||
| 426 | 426 | ||||
| 427 | - `"prompt"` — submits `prompt` as a fresh user message; the agent | 427 | - `"prompt"` — submits `prompt` as a fresh user message; the agent | ||
| 428 | runs a turn against it. | 428 | runs a turn against it. | ||
| 429 | + | - `"tool_prompt"`: submits `prompt`, executes `tool_name` with JSON-object | |||
| 430 | + | `tool_args` through the active agent's normal tool guards and confirmation, | |||
| 431 | + | then records a paired assistant tool call and tool result before the model | |||
| 432 | + | turn. The full tool output is stored in the session and visible to the | |||
| 433 | + | model, so extensions should only request this for content intended to be | |||
| 434 | + | persistent model context. Tool errors are recorded as tool errors. | |||
| 435 | + | Cancellation stops before the model turn and preserves any completed | |||
| 436 | + | call/result pair. This action is supported for interactive slash commands, | |||
| 437 | + | not spontaneous `call_tool` requests. The extension does not receive the | |||
| 438 | + | tool result in the response. | |||
| 429 | - `"insert"` — inserts `insert` into the editor at the cursor without | 439 | - `"insert"` — inserts `insert` into the editor at the cursor without | ||
| 430 | submitting. | 440 | submitting. | ||
| 431 | - `"display"` — appends `display` to the chat as a one-shot styled | 441 | - `"display"` — appends `display` to the chat as a one-shot styled | ||
| @@ -436,6 +446,27 @@ chain. `before_agent_start` follows the non-blocking replacement rules above. | |||||
| 436 | `notify` frames or kicked off background work). zot doesn't change | 446 | `notify` frames or kicked off background work). zot doesn't change | ||
| 437 | the UI in response. | 447 | the UI in response. | ||
| 438 | 448 | ||||
| 449 | + | For a skill shortcut, return the request without copying the skill output | |||
| 450 | + | into the prompt: | |||
| 451 | + | ||||
| 452 | + | ```json | |||
| 453 | + | {"type":"command_response","id":"...","action":"tool_prompt", | |||
| 454 | + | "prompt":"and push","tool_name":"skill", | |||
| 455 | + | "tool_args":{"name":"developer:commit"}} | |||
| 456 | + | ``` | |||
| 457 | + | ||||
| 458 | + | Go SDK: `return ext.ToolPrompt("skill", map[string]string{"name": "developer:commit"}, args)`. | |||
| 459 | + | Unlike `CallTool`, this executes after the command handler returns. Avoid | |||
| 460 | + | calling the same tool in the handler or the work will run twice. The recorded | |||
| 461 | + | assistant call includes `origin_extension` metadata. Older hosts do not | |||
| 462 | + | understand the new action and report an unknown action instead of running it. | |||
| 463 | + | For Gemini 3, zot adds Google's documented | |||
| 464 | + | [`skip_thought_signature_validator`](https://ai.google.dev/gemini-api/docs/generate-content/thought-signatures) | |||
| 465 | + | placeholder to unsigned synthetic function calls when serializing requests. | |||
| 466 | + | Provider-issued thought signatures are preserved. The extension-to-Gemini | |||
| 467 | + | request and session replay are covered with a local Gemini-compatible server, | |||
| 468 | + | not a live Gemini API round trip. | |||
| 469 | + | ||||
| 439 | Example: | 470 | Example: | ||
| 440 | 471 | ||||
| 441 | ```json | 472 | ```json | ||
M packages/agent/ext/ext.go +19 -1
| @@ -255,8 +255,10 @@ type Panel struct { | |||
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | 257 | type Response struct { | |
| 258 | - | Action string // "prompt", "insert", "display", "open_panel", "noop" | |
| 258 | + | Action string // "prompt", "tool_prompt", "insert", "display", "open_panel", "noop" | |
| 259 | 259 | Prompt string | |
| 260 | + | ToolName string | |
| 261 | + | ToolArgs json.RawMessage | |
| 260 | 262 | Insert string | |
| 261 | 263 | Display string | |
| 262 | 264 | OpenPanel *Panel | |
| @@ -268,6 +270,20 @@ type Response struct { | |||
| 268 | 270 | // typed and pressed enter). | |
| 269 | 271 | func Prompt(text string) Response { return Response{Action: "prompt", Prompt: text} } | |
| 270 | 272 | ||
| 273 | + | // ToolPrompt asks the host to run a tool and show its result to the model | |
| 274 | + | // after text has been submitted as a user prompt. Unlike CallTool, execution | |
| 275 | + | // takes place after the command handler returns and is not returned to it. | |
| 276 | + | func ToolPrompt(name string, args any, text string) Response { | |
| 277 | + | if name == "" || text == "" { | |
| 278 | + | return Errorf("tool_prompt requires a name and prompt") | |
| 279 | + | } | |
| 280 | + | data, err := json.Marshal(args) | |
| 281 | + | if err != nil || len(data) == 0 || data[0] != '{' { | |
| 282 | + | return Errorf("tool_prompt args must be a JSON object") | |
| 283 | + | } | |
| 284 | + | return Response{Action: "tool_prompt", Prompt: text, ToolName: name, ToolArgs: data} | |
| 285 | + | } | |
| 286 | + | ||
| 271 | 287 | // Insert returns a Response that drops text into the editor at the | |
| 272 | 288 | // cursor without submitting. | |
| 273 | 289 | func Insert(text string) Response { return Response{Action: "insert", Insert: text} } | |
| @@ -837,6 +853,8 @@ func (e *Extension) respond(id string, r Response) { | |||
| 837 | 853 | ID: id, | |
| 838 | 854 | Action: r.Action, | |
| 839 | 855 | Prompt: r.Prompt, | |
| 856 | + | ToolName: r.ToolName, | |
| 857 | + | ToolArgs: r.ToolArgs, | |
| 840 | 858 | Insert: r.Insert, | |
| 841 | 859 | Display: r.Display, | |
| 842 | 860 | OpenPanel: panel, | |
| @@ -255,8 +255,10 @@ type Panel struct { | |||||
| 255 | } | 255 | } | ||
| 256 | 256 | ||||
| 257 | type Response struct { | 257 | type Response struct { | ||
| 258 | - | Action string // "prompt", "insert", "display", "open_panel", "noop" | 258 | + | Action string // "prompt", "tool_prompt", "insert", "display", "open_panel", "noop" |
| 259 | Prompt string | 259 | Prompt string | ||
| 260 | + | ToolName string | |||
| 261 | + | ToolArgs json.RawMessage | |||
| 260 | Insert string | 262 | Insert string | ||
| 261 | Display string | 263 | Display string | ||
| 262 | OpenPanel *Panel | 264 | OpenPanel *Panel | ||
| @@ -268,6 +270,20 @@ type Response struct { | |||||
| 268 | // typed and pressed enter). | 270 | // typed and pressed enter). | ||
| 269 | func Prompt(text string) Response { return Response{Action: "prompt", Prompt: text} } | 271 | func Prompt(text string) Response { return Response{Action: "prompt", Prompt: text} } | ||
| 270 | 272 | ||||
| 273 | + | // ToolPrompt asks the host to run a tool and show its result to the model | |||
| 274 | + | // after text has been submitted as a user prompt. Unlike CallTool, execution | |||
| 275 | + | // takes place after the command handler returns and is not returned to it. | |||
| 276 | + | func ToolPrompt(name string, args any, text string) Response { | |||
| 277 | + | if name == "" || text == "" { | |||
| 278 | + | return Errorf("tool_prompt requires a name and prompt") | |||
| 279 | + | } | |||
| 280 | + | data, err := json.Marshal(args) | |||
| 281 | + | if err != nil || len(data) == 0 || data[0] != '{' { | |||
| 282 | + | return Errorf("tool_prompt args must be a JSON object") | |||
| 283 | + | } | |||
| 284 | + | return Response{Action: "tool_prompt", Prompt: text, ToolName: name, ToolArgs: data} | |||
| 285 | + | } | |||
| 286 | + | ||||
| 271 | // Insert returns a Response that drops text into the editor at the | 287 | // Insert returns a Response that drops text into the editor at the | ||
| 272 | // cursor without submitting. | 288 | // cursor without submitting. | ||
| 273 | func Insert(text string) Response { return Response{Action: "insert", Insert: text} } | 289 | func Insert(text string) Response { return Response{Action: "insert", Insert: text} } | ||
| @@ -837,6 +853,8 @@ func (e *Extension) respond(id string, r Response) { | |||||
| 837 | ID: id, | 853 | ID: id, | ||
| 838 | Action: r.Action, | 854 | Action: r.Action, | ||
| 839 | Prompt: r.Prompt, | 855 | Prompt: r.Prompt, | ||
| 856 | + | ToolName: r.ToolName, | |||
| 857 | + | ToolArgs: r.ToolArgs, | |||
| 840 | Insert: r.Insert, | 858 | Insert: r.Insert, | ||
| 841 | Display: r.Display, | 859 | Display: r.Display, | ||
| 842 | OpenPanel: panel, | 860 | OpenPanel: panel, | ||
A packages/agent/ext/tool_prompt_test.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package ext | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestToolPromptResponse(t *testing.T) { | |
| 12 | + | h := newHarness("shortcut") | |
| 13 | + | h.ext.Command("commit", "commit", func(args string) Response { | |
| 14 | + | return ToolPrompt("skill", map[string]string{"name": "developer:commit"}, args) | |
| 15 | + | }) | |
| 16 | + | done := make(chan error, 1) | |
| 17 | + | go func() { done <- h.ext.Run() }() | |
| 18 | + | t.Cleanup(func() { | |
| 19 | + | _ = h.hostW.Close() | |
| 20 | + | select { | |
| 21 | + | case err := <-done: | |
| 22 | + | if err != nil { | |
| 23 | + | t.Error(err) | |
| 24 | + | } | |
| 25 | + | case <-time.After(time.Second): | |
| 26 | + | t.Error("SDK did not exit") | |
| 27 | + | } | |
| 28 | + | }) | |
| 29 | + | h.drainUntil(t, "hello") | |
| 30 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack"}) | |
| 31 | + | h.drainUntil(t, "ready") | |
| 32 | + | h.sendToExt(t, extproto.CommandInvokedFromHost{Type: "command_invoked", ID: "1", Name: "commit", Args: "and push"}) | |
| 33 | + | frame := h.drainUntil(t, "command_response") | |
| 34 | + | var decoded extproto.CommandResponseFromExt | |
| 35 | + | if err := json.Unmarshal(frame.raw, &decoded); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | if decoded.ID != "1" || decoded.Action != "tool_prompt" || decoded.Prompt != "and push" || decoded.ToolName != "skill" || string(decoded.ToolArgs) != `{"name":"developer:commit"}` { | |
| 39 | + | t.Fatalf("response = %+v", decoded) | |
| 40 | + | } | |
| 41 | + | if got := ToolPrompt("skill", []string{"not", "object"}, "request"); got.Error == "" { | |
| 42 | + | t.Fatalf("invalid args accepted: %+v", got) | |
| 43 | + | } | |
| 44 | + | } | |
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | package ext | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestToolPromptResponse(t *testing.T) { | |||
| 12 | + | h := newHarness("shortcut") | |||
| 13 | + | h.ext.Command("commit", "commit", func(args string) Response { | |||
| 14 | + | return ToolPrompt("skill", map[string]string{"name": "developer:commit"}, args) | |||
| 15 | + | }) | |||
| 16 | + | done := make(chan error, 1) | |||
| 17 | + | go func() { done <- h.ext.Run() }() | |||
| 18 | + | t.Cleanup(func() { | |||
| 19 | + | _ = h.hostW.Close() | |||
| 20 | + | select { | |||
| 21 | + | case err := <-done: | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Error(err) | |||
| 24 | + | } | |||
| 25 | + | case <-time.After(time.Second): | |||
| 26 | + | t.Error("SDK did not exit") | |||
| 27 | + | } | |||
| 28 | + | }) | |||
| 29 | + | h.drainUntil(t, "hello") | |||
| 30 | + | h.sendToExt(t, extproto.HelloAckFromHost{Type: "hello_ack"}) | |||
| 31 | + | h.drainUntil(t, "ready") | |||
| 32 | + | h.sendToExt(t, extproto.CommandInvokedFromHost{Type: "command_invoked", ID: "1", Name: "commit", Args: "and push"}) | |||
| 33 | + | frame := h.drainUntil(t, "command_response") | |||
| 34 | + | var decoded extproto.CommandResponseFromExt | |||
| 35 | + | if err := json.Unmarshal(frame.raw, &decoded); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | if decoded.ID != "1" || decoded.Action != "tool_prompt" || decoded.Prompt != "and push" || decoded.ToolName != "skill" || string(decoded.ToolArgs) != `{"name":"developer:commit"}` { | |||
| 39 | + | t.Fatalf("response = %+v", decoded) | |||
| 40 | + | } | |||
| 41 | + | if got := ToolPrompt("skill", []string{"not", "object"}, "request"); got.Error == "" { | |||
| 42 | + | t.Fatalf("invalid args accepted: %+v", got) | |||
| 43 | + | } | |||
| 44 | + | } | |||
M packages/agent/extproto/extproto.go +10 -8
| @@ -84,14 +84,16 @@ type ContentBlock struct { | |||
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | 86 | type CommandResponseFromExt struct { | |
| 87 | - | Type string `json:"type"` | |
| 88 | - | ID string `json:"id"` | |
| 89 | - | Action string `json:"action"` | |
| 90 | - | Prompt string `json:"prompt,omitempty"` | |
| 91 | - | Insert string `json:"insert,omitempty"` | |
| 92 | - | Display string `json:"display,omitempty"` | |
| 93 | - | OpenPanel *PanelSpec `json:"open_panel,omitempty"` | |
| 94 | - | Error string `json:"error,omitempty"` | |
| 87 | + | Type string `json:"type"` | |
| 88 | + | ID string `json:"id"` | |
| 89 | + | Action string `json:"action"` | |
| 90 | + | Prompt string `json:"prompt,omitempty"` | |
| 91 | + | ToolName string `json:"tool_name,omitempty"` | |
| 92 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 93 | + | Insert string `json:"insert,omitempty"` | |
| 94 | + | Display string `json:"display,omitempty"` | |
| 95 | + | OpenPanel *PanelSpec `json:"open_panel,omitempty"` | |
| 96 | + | Error string `json:"error,omitempty"` | |
| 95 | 97 | } | |
| 96 | 98 | ||
| 97 | 99 | type PanelSpec struct { | |
| @@ -84,14 +84,16 @@ type ContentBlock struct { | |||||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | type CommandResponseFromExt struct { | 86 | type CommandResponseFromExt struct { | ||
| 87 | - | Type string `json:"type"` | 87 | + | Type string `json:"type"` |
| 88 | - | ID string `json:"id"` | 88 | + | ID string `json:"id"` |
| 89 | - | Action string `json:"action"` | 89 | + | Action string `json:"action"` |
| 90 | - | Prompt string `json:"prompt,omitempty"` | 90 | + | Prompt string `json:"prompt,omitempty"` |
| 91 | - | Insert string `json:"insert,omitempty"` | 91 | + | ToolName string `json:"tool_name,omitempty"` |
| 92 | - | Display string `json:"display,omitempty"` | 92 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` |
| 93 | - | OpenPanel *PanelSpec `json:"open_panel,omitempty"` | 93 | + | Insert string `json:"insert,omitempty"` |
| 94 | - | Error string `json:"error,omitempty"` | 94 | + | Display string `json:"display,omitempty"` |
| 95 | + | OpenPanel *PanelSpec `json:"open_panel,omitempty"` | |||
| 96 | + | Error string `json:"error,omitempty"` | |||
| 95 | } | 97 | } | ||
| 96 | 98 | ||||
| 97 | type PanelSpec struct { | 99 | type PanelSpec struct { | ||
M packages/agent/modes/interactive.go +37 -2
| @@ -509,6 +509,7 @@ type Interactive struct { | |||
| 509 | 509 | // already appended to the agent transcript, avoiding a duplicate message. | |
| 510 | 510 | pendingCompactPrompt string | |
| 511 | 511 | pendingCompactImages []provider.ImageBlock | |
| 512 | + | pendingCompactTool *toolPromptRequest | |
| 512 | 513 | hasPendingCompactPrompt bool | |
| 513 | 514 | continueAfterCompact bool | |
| 514 | 515 | ||
| @@ -3145,6 +3146,18 @@ func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args str | |||
| 3145 | 3146 | return | |
| 3146 | 3147 | } | |
| 3147 | 3148 | i.startTurn(i.runCtx, resp.Prompt) | |
| 3149 | + | case "tool_prompt": | |
| 3150 | + | if strings.TrimSpace(resp.Prompt) == "" || resp.ToolName == "" || !json.Valid(resp.ToolArgs) || !strings.HasPrefix(strings.TrimSpace(string(resp.ToolArgs)), "{") { | |
| 3151 | + | i.mu.Lock() | |
| 3152 | + | i.statusErr = "extension /" + name + ": invalid tool_prompt" | |
| 3153 | + | i.mu.Unlock() | |
| 3154 | + | i.invalidate() | |
| 3155 | + | return | |
| 3156 | + | } | |
| 3157 | + | i.startTurnWithPrelude(i.runCtx, resp.Prompt, nil, &toolPromptRequest{ | |
| 3158 | + | call: provider.ToolCallBlock{ID: "ext-prompt-" + resp.ID, Name: resp.ToolName, Arguments: resp.ToolArgs}, | |
| 3159 | + | origin: i.cfg.Extensions.CommandOwner(name), | |
| 3160 | + | }) | |
| 3148 | 3161 | case "insert": | |
| 3149 | 3162 | i.ed.Insert(resp.Insert) | |
| 3150 | 3163 | i.invalidate() | |
| @@ -5876,6 +5889,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 5876 | 5889 | func (i *Interactive) clearPendingCompactTurnLocked() { | |
| 5877 | 5890 | i.pendingCompactPrompt = "" | |
| 5878 | 5891 | i.pendingCompactImages = nil | |
| 5892 | + | i.pendingCompactTool = nil | |
| 5879 | 5893 | i.hasPendingCompactPrompt = false | |
| 5880 | 5894 | i.continueAfterCompact = false | |
| 5881 | 5895 | i.pendingPostCompactNote = "" | |
| @@ -5934,6 +5948,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 5934 | 5948 | // prompts typed during compaction remain in the host queue. | |
| 5935 | 5949 | var next string | |
| 5936 | 5950 | var nextImages []provider.ImageBlock | |
| 5951 | + | var nextTool *toolPromptRequest | |
| 5937 | 5952 | var hasNext bool | |
| 5938 | 5953 | var continueExisting bool | |
| 5939 | 5954 | ||
| @@ -5988,6 +6003,8 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 5988 | 6003 | case i.hasPendingCompactPrompt: | |
| 5989 | 6004 | next = i.pendingCompactPrompt | |
| 5990 | 6005 | nextImages = i.pendingCompactImages | |
| 6006 | + | nextTool = i.pendingCompactTool | |
| 6007 | + | i.pendingCompactTool = nil | |
| 5991 | 6008 | i.pendingCompactPrompt = "" | |
| 5992 | 6009 | i.pendingCompactImages = nil | |
| 5993 | 6010 | i.hasPendingCompactPrompt = false | |
| @@ -6014,7 +6031,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 6014 | 6031 | if continueExisting { | |
| 6015 | 6032 | i.startTurnRequest(p, "", nil, true) | |
| 6016 | 6033 | } else { | |
| 6017 | - | i.startTurnWithImages(p, next, nextImages) | |
| 6034 | + | i.startTurnWithPrelude(p, next, nextImages, nextTool) | |
| 6018 | 6035 | } | |
| 6019 | 6036 | } | |
| 6020 | 6037 | }() | |
| @@ -6152,10 +6169,23 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) { | |||
| 6152 | 6169 | } | |
| 6153 | 6170 | ||
| 6154 | 6171 | func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) { | |
| 6155 | - | i.startTurnRequest(parent, prompt, images, false) | |
| 6172 | + | i.startTurnWithPrelude(parent, prompt, images, nil) | |
| 6173 | + | } | |
| 6174 | + | ||
| 6175 | + | type toolPromptRequest struct { | |
| 6176 | + | call provider.ToolCallBlock | |
| 6177 | + | origin string | |
| 6178 | + | } | |
| 6179 | + | ||
| 6180 | + | func (i *Interactive) startTurnWithPrelude(parent context.Context, prompt string, images []provider.ImageBlock, tool *toolPromptRequest) { | |
| 6181 | + | i.startTurnRequestWithPrelude(parent, prompt, images, false, tool) | |
| 6156 | 6182 | } | |
| 6157 | 6183 | ||
| 6158 | 6184 | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) { | |
| 6185 | + | i.startTurnRequestWithPrelude(parent, prompt, images, overflowRecoveryAttempted, nil) | |
| 6186 | + | } | |
| 6187 | + | ||
| 6188 | + | func (i *Interactive) startTurnRequestWithPrelude(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool, tool *toolPromptRequest) { | |
| 6159 | 6189 | if i.agent == nil { | |
| 6160 | 6190 | // Text startup pre cannot run without credentials; continue so | |
| 6161 | 6191 | // deferred InitialInput (pre-fill or auto-submit) still applies. | |
| @@ -6177,6 +6207,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6177 | 6207 | if needsPreCompact { | |
| 6178 | 6208 | i.pendingCompactPrompt = prompt | |
| 6179 | 6209 | i.pendingCompactImages = append([]provider.ImageBlock(nil), images...) | |
| 6210 | + | i.pendingCompactTool = tool | |
| 6180 | 6211 | i.hasPendingCompactPrompt = true | |
| 6181 | 6212 | i.statusErr = "" | |
| 6182 | 6213 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | |
| @@ -6241,6 +6272,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 6241 | 6272 | var err error | |
| 6242 | 6273 | if overflowRecoveryAttempted { | |
| 6243 | 6274 | err = i.agent.Continue(ctx, sink) | |
| 6275 | + | } else if tool != nil { | |
| 6276 | + | stopTracking := i.cfg.Extensions.TrackToolCall(tool.call.ID, tool.origin) | |
| 6277 | + | err = i.agent.PromptWithTool(ctx, prompt, tool.call, tool.origin, sink) | |
| 6278 | + | stopTracking() | |
| 6244 | 6279 | } else { | |
| 6245 | 6280 | err = i.agent.Prompt(ctx, prompt, images, sink) | |
| 6246 | 6281 | } | |
| @@ -509,6 +509,7 @@ type Interactive struct { | |||||
| 509 | // already appended to the agent transcript, avoiding a duplicate message. | 509 | // already appended to the agent transcript, avoiding a duplicate message. | ||
| 510 | pendingCompactPrompt string | 510 | pendingCompactPrompt string | ||
| 511 | pendingCompactImages []provider.ImageBlock | 511 | pendingCompactImages []provider.ImageBlock | ||
| 512 | + | pendingCompactTool *toolPromptRequest | |||
| 512 | hasPendingCompactPrompt bool | 513 | hasPendingCompactPrompt bool | ||
| 513 | continueAfterCompact bool | 514 | continueAfterCompact bool | ||
| 514 | 515 | ||||
| @@ -3145,6 +3146,18 @@ func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args str | |||||
| 3145 | return | 3146 | return | ||
| 3146 | } | 3147 | } | ||
| 3147 | i.startTurn(i.runCtx, resp.Prompt) | 3148 | i.startTurn(i.runCtx, resp.Prompt) | ||
| 3149 | + | case "tool_prompt": | |||
| 3150 | + | if strings.TrimSpace(resp.Prompt) == "" || resp.ToolName == "" || !json.Valid(resp.ToolArgs) || !strings.HasPrefix(strings.TrimSpace(string(resp.ToolArgs)), "{") { | |||
| 3151 | + | i.mu.Lock() | |||
| 3152 | + | i.statusErr = "extension /" + name + ": invalid tool_prompt" | |||
| 3153 | + | i.mu.Unlock() | |||
| 3154 | + | i.invalidate() | |||
| 3155 | + | return | |||
| 3156 | + | } | |||
| 3157 | + | i.startTurnWithPrelude(i.runCtx, resp.Prompt, nil, &toolPromptRequest{ | |||
| 3158 | + | call: provider.ToolCallBlock{ID: "ext-prompt-" + resp.ID, Name: resp.ToolName, Arguments: resp.ToolArgs}, | |||
| 3159 | + | origin: i.cfg.Extensions.CommandOwner(name), | |||
| 3160 | + | }) | |||
| 3148 | case "insert": | 3161 | case "insert": | ||
| 3149 | i.ed.Insert(resp.Insert) | 3162 | i.ed.Insert(resp.Insert) | ||
| 3150 | i.invalidate() | 3163 | i.invalidate() | ||
| @@ -5876,6 +5889,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||||
| 5876 | func (i *Interactive) clearPendingCompactTurnLocked() { | 5889 | func (i *Interactive) clearPendingCompactTurnLocked() { | ||
| 5877 | i.pendingCompactPrompt = "" | 5890 | i.pendingCompactPrompt = "" | ||
| 5878 | i.pendingCompactImages = nil | 5891 | i.pendingCompactImages = nil | ||
| 5892 | + | i.pendingCompactTool = nil | |||
| 5879 | i.hasPendingCompactPrompt = false | 5893 | i.hasPendingCompactPrompt = false | ||
| 5880 | i.continueAfterCompact = false | 5894 | i.continueAfterCompact = false | ||
| 5881 | i.pendingPostCompactNote = "" | 5895 | i.pendingPostCompactNote = "" | ||
| @@ -5934,6 +5948,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 5934 | // prompts typed during compaction remain in the host queue. | 5948 | // prompts typed during compaction remain in the host queue. | ||
| 5935 | var next string | 5949 | var next string | ||
| 5936 | var nextImages []provider.ImageBlock | 5950 | var nextImages []provider.ImageBlock | ||
| 5951 | + | var nextTool *toolPromptRequest | |||
| 5937 | var hasNext bool | 5952 | var hasNext bool | ||
| 5938 | var continueExisting bool | 5953 | var continueExisting bool | ||
| 5939 | 5954 | ||||
| @@ -5988,6 +6003,8 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 5988 | case i.hasPendingCompactPrompt: | 6003 | case i.hasPendingCompactPrompt: | ||
| 5989 | next = i.pendingCompactPrompt | 6004 | next = i.pendingCompactPrompt | ||
| 5990 | nextImages = i.pendingCompactImages | 6005 | nextImages = i.pendingCompactImages | ||
| 6006 | + | nextTool = i.pendingCompactTool | |||
| 6007 | + | i.pendingCompactTool = nil | |||
| 5991 | i.pendingCompactPrompt = "" | 6008 | i.pendingCompactPrompt = "" | ||
| 5992 | i.pendingCompactImages = nil | 6009 | i.pendingCompactImages = nil | ||
| 5993 | i.hasPendingCompactPrompt = false | 6010 | i.hasPendingCompactPrompt = false | ||
| @@ -6014,7 +6031,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 6014 | if continueExisting { | 6031 | if continueExisting { | ||
| 6015 | i.startTurnRequest(p, "", nil, true) | 6032 | i.startTurnRequest(p, "", nil, true) | ||
| 6016 | } else { | 6033 | } else { | ||
| 6017 | - | i.startTurnWithImages(p, next, nextImages) | 6034 | + | i.startTurnWithPrelude(p, next, nextImages, nextTool) |
| 6018 | } | 6035 | } | ||
| 6019 | } | 6036 | } | ||
| 6020 | }() | 6037 | }() | ||
| @@ -6152,10 +6169,23 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) { | |||||
| 6152 | } | 6169 | } | ||
| 6153 | 6170 | ||||
| 6154 | func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) { | 6171 | func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) { | ||
| 6155 | - | i.startTurnRequest(parent, prompt, images, false) | 6172 | + | i.startTurnWithPrelude(parent, prompt, images, nil) |
| 6173 | + | } | |||
| 6174 | + | ||||
| 6175 | + | type toolPromptRequest struct { | |||
| 6176 | + | call provider.ToolCallBlock | |||
| 6177 | + | origin string | |||
| 6178 | + | } | |||
| 6179 | + | ||||
| 6180 | + | func (i *Interactive) startTurnWithPrelude(parent context.Context, prompt string, images []provider.ImageBlock, tool *toolPromptRequest) { | |||
| 6181 | + | i.startTurnRequestWithPrelude(parent, prompt, images, false, tool) | |||
| 6156 | } | 6182 | } | ||
| 6157 | 6183 | ||||
| 6158 | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) { | 6184 | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) { | ||
| 6185 | + | i.startTurnRequestWithPrelude(parent, prompt, images, overflowRecoveryAttempted, nil) | |||
| 6186 | + | } | |||
| 6187 | + | ||||
| 6188 | + | func (i *Interactive) startTurnRequestWithPrelude(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool, tool *toolPromptRequest) { | |||
| 6159 | if i.agent == nil { | 6189 | if i.agent == nil { | ||
| 6160 | // Text startup pre cannot run without credentials; continue so | 6190 | // Text startup pre cannot run without credentials; continue so | ||
| 6161 | // deferred InitialInput (pre-fill or auto-submit) still applies. | 6191 | // deferred InitialInput (pre-fill or auto-submit) still applies. | ||
| @@ -6177,6 +6207,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6177 | if needsPreCompact { | 6207 | if needsPreCompact { | ||
| 6178 | i.pendingCompactPrompt = prompt | 6208 | i.pendingCompactPrompt = prompt | ||
| 6179 | i.pendingCompactImages = append([]provider.ImageBlock(nil), images...) | 6209 | i.pendingCompactImages = append([]provider.ImageBlock(nil), images...) | ||
| 6210 | + | i.pendingCompactTool = tool | |||
| 6180 | i.hasPendingCompactPrompt = true | 6211 | i.hasPendingCompactPrompt = true | ||
| 6181 | i.statusErr = "" | 6212 | i.statusErr = "" | ||
| 6182 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | 6213 | i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending...")) | ||
| @@ -6241,6 +6272,10 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 6241 | var err error | 6272 | var err error | ||
| 6242 | if overflowRecoveryAttempted { | 6273 | if overflowRecoveryAttempted { | ||
| 6243 | err = i.agent.Continue(ctx, sink) | 6274 | err = i.agent.Continue(ctx, sink) | ||
| 6275 | + | } else if tool != nil { | |||
| 6276 | + | stopTracking := i.cfg.Extensions.TrackToolCall(tool.call.ID, tool.origin) | |||
| 6277 | + | err = i.agent.PromptWithTool(ctx, prompt, tool.call, tool.origin, sink) | |||
| 6278 | + | stopTracking() | |||
| 6244 | } else { | 6279 | } else { | ||
| 6245 | err = i.agent.Prompt(ctx, prompt, images, sink) | 6280 | err = i.agent.Prompt(ctx, prompt, images, sink) | ||
| 6246 | } | 6281 | } | ||
A packages/agent/modes/tool_prompt_test.go +286 -0
| @@ -0,0 +1,286 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |
| 15 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |
| 16 | + | "github.com/patriceckhart/zot/packages/core" | |
| 17 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | type toolPromptClient struct{ requests chan provider.Request } | |
| 21 | + | ||
| 22 | + | func (*toolPromptClient) Name() string { return "tool-prompt-test" } | |
| 23 | + | func (c *toolPromptClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 24 | + | c.requests <- req | |
| 25 | + | out := make(chan provider.Event, 1) | |
| 26 | + | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}} | |
| 27 | + | close(out) | |
| 28 | + | return out, nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type shortcutTool struct{} | |
| 32 | + | ||
| 33 | + | func (*shortcutTool) Name() string { return "skill" } | |
| 34 | + | func (*shortcutTool) Description() string { return "skill" } | |
| 35 | + | func (*shortcutTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } | |
| 36 | + | func (*shortcutTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) { | |
| 37 | + | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "instructions"}}}, nil | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // TestToolPromptExtensionProcess runs only as a child started by the manager. | |
| 41 | + | func TestToolPromptExtensionProcess(t *testing.T) { | |
| 42 | + | if os.Getenv("ZOT_TOOL_PROMPT_CHILD") != "1" { | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | e := ext.New("shortcut", "test") | |
| 46 | + | e.Command("commit", "commit", func(args string) ext.Response { | |
| 47 | + | return ext.ToolPrompt("skill", map[string]string{"name": "developer:commit"}, args) | |
| 48 | + | }) | |
| 49 | + | if err := e.Run(); err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestToolPromptExtensionCommandWithLocalGemini(t *testing.T) { | |
| 55 | + | root := t.TempDir() | |
| 56 | + | path, err := os.Executable() | |
| 57 | + | if err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | dir := filepath.Join(root, "extensions", "shortcut") | |
| 61 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | manifest, err := json.Marshal(map[string]any{"name": "shortcut", "version": "test", "exec": path, "args": []string{"-test.run=^TestToolPromptExtensionProcess$"}}) | |
| 65 | + | if err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | if err := os.WriteFile(filepath.Join(dir, "extension.json"), manifest, 0o644); err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | t.Setenv("ZOT_TOOL_PROMPT_CHILD", "1") | |
| 72 | + | mgr := extensions.New(root, "", "test", "google", "gemini-3-flash-preview", nil) | |
| 73 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |
| 74 | + | t.Fatal(errs) | |
| 75 | + | } | |
| 76 | + | t.Cleanup(func() { mgr.Stop(time.Second) }) | |
| 77 | + | mgr.WaitForReady(2 * time.Second) | |
| 78 | + | if !mgr.HasCommand("commit") { | |
| 79 | + | t.Fatal("extension command not registered") | |
| 80 | + | } | |
| 81 | + | requests := make(chan json.RawMessage, 1) | |
| 82 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 83 | + | defer r.Body.Close() | |
| 84 | + | var raw json.RawMessage | |
| 85 | + | if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { | |
| 86 | + | http.Error(w, err.Error(), 400) | |
| 87 | + | return | |
| 88 | + | } | |
| 89 | + | requests <- raw | |
| 90 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 91 | + | _, _ = fmt.Fprint(w, "data: "+`{"candidates":[{"content":{"role":"model","parts":[{"text":"done"}]},"finishReason":"STOP"}]}`+"\n\n") | |
| 92 | + | })) | |
| 93 | + | defer server.Close() | |
| 94 | + | agent := core.NewAgent(provider.NewGemini("synthetic-test-key", server.URL), "gemini-3-flash-preview", "", core.Registry{"skill": &shortcutTool{}}) | |
| 95 | + | iv := NewInteractive(InteractiveConfig{Agent: agent, Extensions: mgr}) | |
| 96 | + | iv.runCtx = context.Background() | |
| 97 | + | iv.invokeExtensionCommand(context.Background(), "commit", "and push") | |
| 98 | + | select { | |
| 99 | + | case raw := <-requests: | |
| 100 | + | var wire struct { | |
| 101 | + | Contents []struct { | |
| 102 | + | Parts []struct { | |
| 103 | + | Text string `json:"text"` | |
| 104 | + | Signature string `json:"thoughtSignature"` | |
| 105 | + | FunctionCall *struct { | |
| 106 | + | Name string `json:"name"` | |
| 107 | + | } `json:"functionCall"` | |
| 108 | + | FunctionResponse *struct { | |
| 109 | + | Response map[string]string `json:"response"` | |
| 110 | + | } `json:"functionResponse"` | |
| 111 | + | } `json:"parts"` | |
| 112 | + | } `json:"contents"` | |
| 113 | + | } | |
| 114 | + | if err := json.Unmarshal(raw, &wire); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | if len(wire.Contents) != 3 || wire.Contents[0].Parts[0].Text != "and push" || wire.Contents[1].Parts[0].FunctionCall == nil || wire.Contents[1].Parts[0].FunctionCall.Name != "skill" || wire.Contents[1].Parts[0].Signature != "skip_thought_signature_validator" || wire.Contents[2].Parts[0].FunctionResponse == nil || wire.Contents[2].Parts[0].FunctionResponse.Response["output"] != "instructions" { | |
| 118 | + | t.Fatalf("Gemini request: %+v", wire) | |
| 119 | + | } | |
| 120 | + | case <-time.After(3 * time.Second): | |
| 121 | + | t.Fatal("extension command did not send Gemini request") | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // This exercises the interactive prelude, core transcript, Gemini request | |
| 126 | + | // serialization, streaming, and session replay without contacting Google. | |
| 127 | + | func TestToolPromptGeminiLocalRoundTripAndResume(t *testing.T) { | |
| 128 | + | requests := make(chan []byte, 2) | |
| 129 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 130 | + | defer r.Body.Close() | |
| 131 | + | var raw json.RawMessage | |
| 132 | + | if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { | |
| 133 | + | http.Error(w, err.Error(), http.StatusBadRequest) | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | requests <- raw | |
| 137 | + | w.Header().Set("Content-Type", "text/event-stream") | |
| 138 | + | _, _ = fmt.Fprint(w, "data: "+`{"candidates":[{"content":{"role":"model","parts":[{"text":"done"}]},"finishReason":"STOP"}]}`+"\n\n") | |
| 139 | + | })) | |
| 140 | + | defer server.Close() | |
| 141 | + | ||
| 142 | + | client := provider.NewGemini("synthetic-test-key", server.URL) | |
| 143 | + | agent := core.NewAgent(client, "gemini-3-flash-preview", "", core.Registry{"skill": &shortcutTool{}}) | |
| 144 | + | session, err := core.NewSession(t.TempDir(), t.TempDir(), "google", "gemini-3-flash-preview", "test") | |
| 145 | + | if err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | agent.OnMessageAppended = func(m provider.Message) { | |
| 149 | + | if err := session.AppendMessage(m); err != nil { | |
| 150 | + | t.Error(err) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | iv := NewInteractive(InteractiveConfig{Agent: agent, Extensions: &extensions.Manager{}}) | |
| 154 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 155 | + | defer cancel() | |
| 156 | + | iv.startTurnWithPrelude(ctx, "and push", nil, &toolPromptRequest{ | |
| 157 | + | call: provider.ToolCallBlock{ID: "ext-prompt-local", Name: "skill", Arguments: json.RawMessage(`{"name":"developer:commit"}`)}, origin: "shortcut", | |
| 158 | + | }) | |
| 159 | + | ||
| 160 | + | var wire struct { | |
| 161 | + | Contents []struct { | |
| 162 | + | Role string `json:"role"` | |
| 163 | + | Parts []struct { | |
| 164 | + | Text string `json:"text"` | |
| 165 | + | Signature string `json:"thoughtSignature"` | |
| 166 | + | FunctionCall *struct { | |
| 167 | + | Name string `json:"name"` | |
| 168 | + | } `json:"functionCall"` | |
| 169 | + | FunctionResponse *struct { | |
| 170 | + | Name string `json:"name"` | |
| 171 | + | Response map[string]string `json:"response"` | |
| 172 | + | } `json:"functionResponse"` | |
| 173 | + | } `json:"parts"` | |
| 174 | + | } `json:"contents"` | |
| 175 | + | } | |
| 176 | + | select { | |
| 177 | + | case raw := <-requests: | |
| 178 | + | if err := json.Unmarshal(raw, &wire); err != nil { | |
| 179 | + | t.Fatal(err) | |
| 180 | + | } | |
| 181 | + | case <-time.After(3 * time.Second): | |
| 182 | + | t.Fatal("Gemini request not sent") | |
| 183 | + | } | |
| 184 | + | if len(wire.Contents) != 3 || wire.Contents[0].Parts[0].Text != "and push" || wire.Contents[1].Role != "model" || wire.Contents[1].Parts[0].FunctionCall == nil || wire.Contents[1].Parts[0].FunctionCall.Name != "skill" || wire.Contents[1].Parts[0].Signature != "skip_thought_signature_validator" || wire.Contents[2].Parts[0].FunctionResponse == nil || wire.Contents[2].Parts[0].FunctionResponse.Response["output"] != "instructions" { | |
| 185 | + | t.Fatalf("Gemini request did not contain one matched synthetic tool exchange: %+v", wire) | |
| 186 | + | } | |
| 187 | + | deadline := time.NewTimer(3 * time.Second) | |
| 188 | + | defer deadline.Stop() | |
| 189 | + | poll := time.NewTicker(5 * time.Millisecond) | |
| 190 | + | defer poll.Stop() | |
| 191 | + | for { | |
| 192 | + | iv.mu.Lock() | |
| 193 | + | busy := iv.busy | |
| 194 | + | iv.mu.Unlock() | |
| 195 | + | if !busy { | |
| 196 | + | break | |
| 197 | + | } | |
| 198 | + | select { | |
| 199 | + | case <-poll.C: | |
| 200 | + | case <-deadline.C: | |
| 201 | + | t.Fatal("Gemini turn did not finish") | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | path := session.Path | |
| 205 | + | if err := session.Close(); err != nil { | |
| 206 | + | t.Fatal(err) | |
| 207 | + | } | |
| 208 | + | reopened, msgs, err := core.OpenSession(path) | |
| 209 | + | if err != nil { | |
| 210 | + | t.Fatal(err) | |
| 211 | + | } | |
| 212 | + | t.Cleanup(func() { _ = reopened.Close() }) | |
| 213 | + | if len(msgs) != 4 || msgs[1].Meta["synthetic_tool_call"] != "true" { | |
| 214 | + | t.Fatalf("restored transcript: %+v", msgs) | |
| 215 | + | } | |
| 216 | + | resumed := core.NewAgent(client, "gemini-3-flash-preview", "", core.Registry{"skill": &shortcutTool{}}) | |
| 217 | + | resumed.SetMessages(msgs) | |
| 218 | + | if err := resumed.Prompt(ctx, "continue", nil, nil); err != nil { | |
| 219 | + | t.Fatal(err) | |
| 220 | + | } | |
| 221 | + | select { | |
| 222 | + | case raw := <-requests: | |
| 223 | + | if err := json.Unmarshal(raw, &wire); err != nil { | |
| 224 | + | t.Fatal(err) | |
| 225 | + | } | |
| 226 | + | case <-time.After(3 * time.Second): | |
| 227 | + | t.Fatal("resumed Gemini request not sent") | |
| 228 | + | } | |
| 229 | + | if len(wire.Contents) != 5 || wire.Contents[1].Parts[0].Signature != "skip_thought_signature_validator" || wire.Contents[2].Parts[0].FunctionResponse.Response["output"] != "instructions" || wire.Contents[4].Parts[0].Text != "continue" { | |
| 230 | + | t.Fatalf("Gemini replay: %+v", wire) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func TestToolPromptSurvivesPreTurnCompaction(t *testing.T) { | |
| 235 | + | client := &compactQueueClient{ | |
| 236 | + | compactionStarted: make(chan struct{}), | |
| 237 | + | releaseCompaction: make(chan struct{}), | |
| 238 | + | followUpRequest: make(chan provider.Request, 1), | |
| 239 | + | } | |
| 240 | + | agent := core.NewAgent(client, "test-model", "", core.Registry{"skill": &shortcutTool{}}) | |
| 241 | + | agent.SetMessages([]provider.Message{ | |
| 242 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |
| 243 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |
| 244 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |
| 245 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |
| 246 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}}, | |
| 247 | + | }) | |
| 248 | + | threshold := 70 | |
| 249 | + | iv := NewInteractive(InteractiveConfig{Agent: agent, Extensions: &extensions.Manager{}, Provider: "anthropic", Model: "claude-sonnet-4-5", AutoCompactThreshold: &threshold}) | |
| 250 | + | iv.runCtx = context.Background() | |
| 251 | + | iv.lastCtxInput = 150000 | |
| 252 | + | iv.startTurnWithPrelude(context.Background(), "and push", nil, &toolPromptRequest{call: provider.ToolCallBlock{ID: "ext-prompt-2", Name: "skill", Arguments: json.RawMessage(`{}`)}, origin: "shortcut"}) | |
| 253 | + | select { | |
| 254 | + | case <-client.compactionStarted: | |
| 255 | + | case <-time.After(2 * time.Second): | |
| 256 | + | t.Fatal("compaction did not start") | |
| 257 | + | } | |
| 258 | + | close(client.releaseCompaction) | |
| 259 | + | select { | |
| 260 | + | case req := <-client.followUpRequest: | |
| 261 | + | if requestUserTextCount(req, "and push") != 1 || len(req.Messages) < 3 || req.Messages[len(req.Messages)-2].Content[0].(provider.ToolCallBlock).Name != "skill" || req.Messages[len(req.Messages)-1].Content[0].(provider.ToolResultBlock).Content[0].(provider.TextBlock).Text != "instructions" { | |
| 262 | + | t.Fatalf("prelude lost after compaction: %+v", req.Messages) | |
| 263 | + | } | |
| 264 | + | case <-time.After(2 * time.Second): | |
| 265 | + | t.Fatal("model request not sent") | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func TestToolPromptStartsModelAfterToolResult(t *testing.T) { | |
| 270 | + | client := &toolPromptClient{requests: make(chan provider.Request, 1)} | |
| 271 | + | agent := core.NewAgent(client, "test-model", "", core.Registry{"skill": &shortcutTool{}}) | |
| 272 | + | iv := NewInteractive(InteractiveConfig{Agent: agent, Extensions: &extensions.Manager{}}) | |
| 273 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 274 | + | defer cancel() | |
| 275 | + | iv.startTurnWithPrelude(ctx, "and push", nil, &toolPromptRequest{ | |
| 276 | + | call: provider.ToolCallBlock{ID: "ext-prompt-1", Name: "skill", Arguments: json.RawMessage(`{"name":"developer:commit"}`)}, origin: "shortcut", | |
| 277 | + | }) | |
| 278 | + | select { | |
| 279 | + | case req := <-client.requests: | |
| 280 | + | if len(req.Messages) != 3 || req.Messages[0].Role != provider.RoleUser || req.Messages[1].Role != provider.RoleAssistant || req.Messages[2].Role != provider.RoleTool || req.Messages[2].Content[0].(provider.ToolResultBlock).Content[0].(provider.TextBlock).Text != "instructions" { | |
| 281 | + | t.Fatalf("model input: %+v", req.Messages) | |
| 282 | + | } | |
| 283 | + | case <-time.After(2 * time.Second): | |
| 284 | + | t.Fatal("model request not sent") | |
| 285 | + | } | |
| 286 | + | } | |
| @@ -0,0 +1,286 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |||
| 15 | + | "github.com/patriceckhart/zot/packages/agent/extensions" | |||
| 16 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 17 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | type toolPromptClient struct{ requests chan provider.Request } | |||
| 21 | + | ||||
| 22 | + | func (*toolPromptClient) Name() string { return "tool-prompt-test" } | |||
| 23 | + | func (c *toolPromptClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 24 | + | c.requests <- req | |||
| 25 | + | out := make(chan provider.Event, 1) | |||
| 26 | + | out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}} | |||
| 27 | + | close(out) | |||
| 28 | + | return out, nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type shortcutTool struct{} | |||
| 32 | + | ||||
| 33 | + | func (*shortcutTool) Name() string { return "skill" } | |||
| 34 | + | func (*shortcutTool) Description() string { return "skill" } | |||
| 35 | + | func (*shortcutTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } | |||
| 36 | + | func (*shortcutTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) { | |||
| 37 | + | return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "instructions"}}}, nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // TestToolPromptExtensionProcess runs only as a child started by the manager. | |||
| 41 | + | func TestToolPromptExtensionProcess(t *testing.T) { | |||
| 42 | + | if os.Getenv("ZOT_TOOL_PROMPT_CHILD") != "1" { | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | e := ext.New("shortcut", "test") | |||
| 46 | + | e.Command("commit", "commit", func(args string) ext.Response { | |||
| 47 | + | return ext.ToolPrompt("skill", map[string]string{"name": "developer:commit"}, args) | |||
| 48 | + | }) | |||
| 49 | + | if err := e.Run(); err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestToolPromptExtensionCommandWithLocalGemini(t *testing.T) { | |||
| 55 | + | root := t.TempDir() | |||
| 56 | + | path, err := os.Executable() | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | dir := filepath.Join(root, "extensions", "shortcut") | |||
| 61 | + | if err := os.MkdirAll(dir, 0o755); err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | manifest, err := json.Marshal(map[string]any{"name": "shortcut", "version": "test", "exec": path, "args": []string{"-test.run=^TestToolPromptExtensionProcess$"}}) | |||
| 65 | + | if err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | if err := os.WriteFile(filepath.Join(dir, "extension.json"), manifest, 0o644); err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | t.Setenv("ZOT_TOOL_PROMPT_CHILD", "1") | |||
| 72 | + | mgr := extensions.New(root, "", "test", "google", "gemini-3-flash-preview", nil) | |||
| 73 | + | if errs := mgr.Discover(context.Background()); len(errs) > 0 { | |||
| 74 | + | t.Fatal(errs) | |||
| 75 | + | } | |||
| 76 | + | t.Cleanup(func() { mgr.Stop(time.Second) }) | |||
| 77 | + | mgr.WaitForReady(2 * time.Second) | |||
| 78 | + | if !mgr.HasCommand("commit") { | |||
| 79 | + | t.Fatal("extension command not registered") | |||
| 80 | + | } | |||
| 81 | + | requests := make(chan json.RawMessage, 1) | |||
| 82 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 83 | + | defer r.Body.Close() | |||
| 84 | + | var raw json.RawMessage | |||
| 85 | + | if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { | |||
| 86 | + | http.Error(w, err.Error(), 400) | |||
| 87 | + | return | |||
| 88 | + | } | |||
| 89 | + | requests <- raw | |||
| 90 | + | w.Header().Set("Content-Type", "text/event-stream") | |||
| 91 | + | _, _ = fmt.Fprint(w, "data: "+`{"candidates":[{"content":{"role":"model","parts":[{"text":"done"}]},"finishReason":"STOP"}]}`+"\n\n") | |||
| 92 | + | })) | |||
| 93 | + | defer server.Close() | |||
| 94 | + | agent := core.NewAgent(provider.NewGemini("synthetic-test-key", server.URL), "gemini-3-flash-preview", "", core.Registry{"skill": &shortcutTool{}}) | |||
| 95 | + | iv := NewInteractive(InteractiveConfig{Agent: agent, Extensions: mgr}) | |||
| 96 | + | iv.runCtx = context.Background() | |||
| 97 | + | iv.invokeExtensionCommand(context.Background(), "commit", "and push") | |||
| 98 | + | select { | |||
| 99 | + | case raw := <-requests: | |||
| 100 | + | var wire struct { | |||
| 101 | + | Contents []struct { | |||
| 102 | + | Parts []struct { | |||
| 103 | + | Text string `json:"text"` | |||
| 104 | + | Signature string `json:"thoughtSignature"` | |||
| 105 | + | FunctionCall *struct { | |||
| 106 | + | Name string `json:"name"` | |||
| 107 | + | } `json:"functionCall"` | |||
| 108 | + | FunctionResponse *struct { | |||
| 109 | + | Response map[string]string `json:"response"` | |||
| 110 | + | } `json:"functionResponse"` | |||
| 111 | + | } `json:"parts"` | |||
| 112 | + | } `json:"contents"` | |||
| 113 | + | } | |||
| 114 | + | if err := json.Unmarshal(raw, &wire); err != nil { | |||
| 115 | + | t.Fatal(err) | |||
| 116 | + | } | |||
| 117 | + | if len(wire.Contents) != 3 || wire.Contents[0].Parts[0].Text != "and push" || wire.Contents[1].Parts[0].FunctionCall == nil || wire.Contents[1].Parts[0].FunctionCall.Name != "skill" || wire.Contents[1].Parts[0].Signature != "skip_thought_signature_validator" || wire.Contents[2].Parts[0].FunctionResponse == nil || wire.Contents[2].Parts[0].FunctionResponse.Response["output"] != "instructions" { | |||
| 118 | + | t.Fatalf("Gemini request: %+v", wire) | |||
| 119 | + | } | |||
| 120 | + | case <-time.After(3 * time.Second): | |||
| 121 | + | t.Fatal("extension command did not send Gemini request") | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // This exercises the interactive prelude, core transcript, Gemini request | |||
| 126 | + | // serialization, streaming, and session replay without contacting Google. | |||
| 127 | + | func TestToolPromptGeminiLocalRoundTripAndResume(t *testing.T) { | |||
| 128 | + | requests := make(chan []byte, 2) | |||
| 129 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 130 | + | defer r.Body.Close() | |||
| 131 | + | var raw json.RawMessage | |||
| 132 | + | if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { | |||
| 133 | + | http.Error(w, err.Error(), http.StatusBadRequest) | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | requests <- raw | |||
| 137 | + | w.Header().Set("Content-Type", "text/event-stream") | |||
| 138 | + | _, _ = fmt.Fprint(w, "data: "+`{"candidates":[{"content":{"role":"model","parts":[{"text":"done"}]},"finishReason":"STOP"}]}`+"\n\n") | |||
| 139 | + | })) | |||
| 140 | + | defer server.Close() | |||
| 141 | + | ||||
| 142 | + | client := provider.NewGemini("synthetic-test-key", server.URL) | |||
| 143 | + | agent := core.NewAgent(client, "gemini-3-flash-preview", "", core.Registry{"skill": &shortcutTool{}}) | |||
| 144 | + | session, err := core.NewSession(t.TempDir(), t.TempDir(), "google", "gemini-3-flash-preview", "test") | |||
| 145 | + | if err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | agent.OnMessageAppended = func(m provider.Message) { | |||
| 149 | + | if err := session.AppendMessage(m); err != nil { | |||
| 150 | + | t.Error(err) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | iv := NewInteractive(InteractiveConfig{Agent: agent, Extensions: &extensions.Manager{}}) | |||
| 154 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 155 | + | defer cancel() | |||
| 156 | + | iv.startTurnWithPrelude(ctx, "and push", nil, &toolPromptRequest{ | |||
| 157 | + | call: provider.ToolCallBlock{ID: "ext-prompt-local", Name: "skill", Arguments: json.RawMessage(`{"name":"developer:commit"}`)}, origin: "shortcut", | |||
| 158 | + | }) | |||
| 159 | + | ||||
| 160 | + | var wire struct { | |||
| 161 | + | Contents []struct { | |||
| 162 | + | Role string `json:"role"` | |||
| 163 | + | Parts []struct { | |||
| 164 | + | Text string `json:"text"` | |||
| 165 | + | Signature string `json:"thoughtSignature"` | |||
| 166 | + | FunctionCall *struct { | |||
| 167 | + | Name string `json:"name"` | |||
| 168 | + | } `json:"functionCall"` | |||
| 169 | + | FunctionResponse *struct { | |||
| 170 | + | Name string `json:"name"` | |||
| 171 | + | Response map[string]string `json:"response"` | |||
| 172 | + | } `json:"functionResponse"` | |||
| 173 | + | } `json:"parts"` | |||
| 174 | + | } `json:"contents"` | |||
| 175 | + | } | |||
| 176 | + | select { | |||
| 177 | + | case raw := <-requests: | |||
| 178 | + | if err := json.Unmarshal(raw, &wire); err != nil { | |||
| 179 | + | t.Fatal(err) | |||
| 180 | + | } | |||
| 181 | + | case <-time.After(3 * time.Second): | |||
| 182 | + | t.Fatal("Gemini request not sent") | |||
| 183 | + | } | |||
| 184 | + | if len(wire.Contents) != 3 || wire.Contents[0].Parts[0].Text != "and push" || wire.Contents[1].Role != "model" || wire.Contents[1].Parts[0].FunctionCall == nil || wire.Contents[1].Parts[0].FunctionCall.Name != "skill" || wire.Contents[1].Parts[0].Signature != "skip_thought_signature_validator" || wire.Contents[2].Parts[0].FunctionResponse == nil || wire.Contents[2].Parts[0].FunctionResponse.Response["output"] != "instructions" { | |||
| 185 | + | t.Fatalf("Gemini request did not contain one matched synthetic tool exchange: %+v", wire) | |||
| 186 | + | } | |||
| 187 | + | deadline := time.NewTimer(3 * time.Second) | |||
| 188 | + | defer deadline.Stop() | |||
| 189 | + | poll := time.NewTicker(5 * time.Millisecond) | |||
| 190 | + | defer poll.Stop() | |||
| 191 | + | for { | |||
| 192 | + | iv.mu.Lock() | |||
| 193 | + | busy := iv.busy | |||
| 194 | + | iv.mu.Unlock() | |||
| 195 | + | if !busy { | |||
| 196 | + | break | |||
| 197 | + | } | |||
| 198 | + | select { | |||
| 199 | + | case <-poll.C: | |||
| 200 | + | case <-deadline.C: | |||
| 201 | + | t.Fatal("Gemini turn did not finish") | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | path := session.Path | |||
| 205 | + | if err := session.Close(); err != nil { | |||
| 206 | + | t.Fatal(err) | |||
| 207 | + | } | |||
| 208 | + | reopened, msgs, err := core.OpenSession(path) | |||
| 209 | + | if err != nil { | |||
| 210 | + | t.Fatal(err) | |||
| 211 | + | } | |||
| 212 | + | t.Cleanup(func() { _ = reopened.Close() }) | |||
| 213 | + | if len(msgs) != 4 || msgs[1].Meta["synthetic_tool_call"] != "true" { | |||
| 214 | + | t.Fatalf("restored transcript: %+v", msgs) | |||
| 215 | + | } | |||
| 216 | + | resumed := core.NewAgent(client, "gemini-3-flash-preview", "", core.Registry{"skill": &shortcutTool{}}) | |||
| 217 | + | resumed.SetMessages(msgs) | |||
| 218 | + | if err := resumed.Prompt(ctx, "continue", nil, nil); err != nil { | |||
| 219 | + | t.Fatal(err) | |||
| 220 | + | } | |||
| 221 | + | select { | |||
| 222 | + | case raw := <-requests: | |||
| 223 | + | if err := json.Unmarshal(raw, &wire); err != nil { | |||
| 224 | + | t.Fatal(err) | |||
| 225 | + | } | |||
| 226 | + | case <-time.After(3 * time.Second): | |||
| 227 | + | t.Fatal("resumed Gemini request not sent") | |||
| 228 | + | } | |||
| 229 | + | if len(wire.Contents) != 5 || wire.Contents[1].Parts[0].Signature != "skip_thought_signature_validator" || wire.Contents[2].Parts[0].FunctionResponse.Response["output"] != "instructions" || wire.Contents[4].Parts[0].Text != "continue" { | |||
| 230 | + | t.Fatalf("Gemini replay: %+v", wire) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func TestToolPromptSurvivesPreTurnCompaction(t *testing.T) { | |||
| 235 | + | client := &compactQueueClient{ | |||
| 236 | + | compactionStarted: make(chan struct{}), | |||
| 237 | + | releaseCompaction: make(chan struct{}), | |||
| 238 | + | followUpRequest: make(chan provider.Request, 1), | |||
| 239 | + | } | |||
| 240 | + | agent := core.NewAgent(client, "test-model", "", core.Registry{"skill": &shortcutTool{}}) | |||
| 241 | + | agent.SetMessages([]provider.Message{ | |||
| 242 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "one"}}}, | |||
| 243 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "two"}}}, | |||
| 244 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "three"}}}, | |||
| 245 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "four"}}}, | |||
| 246 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "five"}}}, | |||
| 247 | + | }) | |||
| 248 | + | threshold := 70 | |||
| 249 | + | iv := NewInteractive(InteractiveConfig{Agent: agent, Extensions: &extensions.Manager{}, Provider: "anthropic", Model: "claude-sonnet-4-5", AutoCompactThreshold: &threshold}) | |||
| 250 | + | iv.runCtx = context.Background() | |||
| 251 | + | iv.lastCtxInput = 150000 | |||
| 252 | + | iv.startTurnWithPrelude(context.Background(), "and push", nil, &toolPromptRequest{call: provider.ToolCallBlock{ID: "ext-prompt-2", Name: "skill", Arguments: json.RawMessage(`{}`)}, origin: "shortcut"}) | |||
| 253 | + | select { | |||
| 254 | + | case <-client.compactionStarted: | |||
| 255 | + | case <-time.After(2 * time.Second): | |||
| 256 | + | t.Fatal("compaction did not start") | |||
| 257 | + | } | |||
| 258 | + | close(client.releaseCompaction) | |||
| 259 | + | select { | |||
| 260 | + | case req := <-client.followUpRequest: | |||
| 261 | + | if requestUserTextCount(req, "and push") != 1 || len(req.Messages) < 3 || req.Messages[len(req.Messages)-2].Content[0].(provider.ToolCallBlock).Name != "skill" || req.Messages[len(req.Messages)-1].Content[0].(provider.ToolResultBlock).Content[0].(provider.TextBlock).Text != "instructions" { | |||
| 262 | + | t.Fatalf("prelude lost after compaction: %+v", req.Messages) | |||
| 263 | + | } | |||
| 264 | + | case <-time.After(2 * time.Second): | |||
| 265 | + | t.Fatal("model request not sent") | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func TestToolPromptStartsModelAfterToolResult(t *testing.T) { | |||
| 270 | + | client := &toolPromptClient{requests: make(chan provider.Request, 1)} | |||
| 271 | + | agent := core.NewAgent(client, "test-model", "", core.Registry{"skill": &shortcutTool{}}) | |||
| 272 | + | iv := NewInteractive(InteractiveConfig{Agent: agent, Extensions: &extensions.Manager{}}) | |||
| 273 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 274 | + | defer cancel() | |||
| 275 | + | iv.startTurnWithPrelude(ctx, "and push", nil, &toolPromptRequest{ | |||
| 276 | + | call: provider.ToolCallBlock{ID: "ext-prompt-1", Name: "skill", Arguments: json.RawMessage(`{"name":"developer:commit"}`)}, origin: "shortcut", | |||
| 277 | + | }) | |||
| 278 | + | select { | |||
| 279 | + | case req := <-client.requests: | |||
| 280 | + | if len(req.Messages) != 3 || req.Messages[0].Role != provider.RoleUser || req.Messages[1].Role != provider.RoleAssistant || req.Messages[2].Role != provider.RoleTool || req.Messages[2].Content[0].(provider.ToolResultBlock).Content[0].(provider.TextBlock).Text != "instructions" { | |||
| 281 | + | t.Fatalf("model input: %+v", req.Messages) | |||
| 282 | + | } | |||
| 283 | + | case <-time.After(2 * time.Second): | |||
| 284 | + | t.Fatal("model request not sent") | |||
| 285 | + | } | |||
| 286 | + | } | |||
M packages/core/agent.go +48 -0
| @@ -345,6 +345,20 @@ func (a *Agent) fireMessageAppended(m provider.Message) { | |||
| 345 | 345 | // stops or an error occurs. Events are delivered via sink in order. | |
| 346 | 346 | // sink must not block the caller for long; buffer as needed. | |
| 347 | 347 | func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error { | |
| 348 | + | return a.promptWithPrelude(ctx, text, images, nil, "", sink) | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | // PromptWithTool runs a host-requested tool after the user prompt and before | |
| 352 | + | // the first model turn. The call and result are persisted as a matched pair. | |
| 353 | + | // A cancelled execution still records its result, but does not call the model. | |
| 354 | + | func (a *Agent) PromptWithTool(ctx context.Context, text string, call provider.ToolCallBlock, origin string, sink func(AgentEvent)) error { | |
| 355 | + | if strings.TrimSpace(text) == "" || call.ID == "" || call.Name == "" || !json.Valid(call.Arguments) || !strings.HasPrefix(strings.TrimSpace(string(call.Arguments)), "{") { | |
| 356 | + | return fmt.Errorf("tool prompt requires text, tool ID, name and JSON object arguments") | |
| 357 | + | } | |
| 358 | + | return a.promptWithPrelude(ctx, text, nil, &call, origin, sink) | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | func (a *Agent) promptWithPrelude(ctx context.Context, text string, images []provider.ImageBlock, call *provider.ToolCallBlock, origin string, sink func(AgentEvent)) error { | |
| 348 | 362 | if sink == nil { | |
| 349 | 363 | sink = func(AgentEvent) {} | |
| 350 | 364 | } | |
| @@ -368,6 +382,40 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image | |||
| 368 | 382 | a.fireMessageAppended(user) | |
| 369 | 383 | sink(EvUserMessage{Message: user}) | |
| 370 | 384 | ||
| 385 | + | if call != nil { | |
| 386 | + | if err := ctx.Err(); err != nil { | |
| 387 | + | return err | |
| 388 | + | } | |
| 389 | + | sink(EvToolCall{ID: call.ID, Name: call.Name, Args: call.Arguments}) | |
| 390 | + | result := a.runOneTool(ctx, *call, sink) | |
| 391 | + | assistant := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{*call}, Time: time.Now(), Meta: map[string]string{"origin_extension": origin, "synthetic_tool_call": "true"}} | |
| 392 | + | tool := provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: call.ID, Content: result.Content, IsError: result.IsError}}, Time: time.Now()} | |
| 393 | + | for _, name := range result.ActivateTools { | |
| 394 | + | if _, err := a.Tools.Get(name); err == nil && !containsString(tool.AddedToolNames, name) { | |
| 395 | + | tool.AddedToolNames = append(tool.AddedToolNames, name) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | var mirror provider.Message | |
| 399 | + | if a.Client != nil && (a.Client.Name() == "openai" || a.Client.Name() == "openai-codex") { | |
| 400 | + | mirror = mirrorToolImagesAsUser(tool) | |
| 401 | + | } | |
| 402 | + | a.mu.Lock() | |
| 403 | + | a.messages = append(a.messages, assistant, tool) | |
| 404 | + | a.rev += 2 | |
| 405 | + | if len(mirror.Content) > 0 { | |
| 406 | + | a.messages = append(a.messages, mirror) | |
| 407 | + | a.rev++ | |
| 408 | + | } | |
| 409 | + | a.mu.Unlock() | |
| 410 | + | a.fireMessageAppended(assistant) | |
| 411 | + | a.fireMessageAppended(tool) | |
| 412 | + | if len(mirror.Content) > 0 { | |
| 413 | + | a.fireMessageAppended(mirror) | |
| 414 | + | } | |
| 415 | + | if err := ctx.Err(); err != nil { | |
| 416 | + | return err | |
| 417 | + | } | |
| 418 | + | } | |
| 371 | 419 | return a.runLoop(ctx, sink) | |
| 372 | 420 | } | |
| 373 | 421 | ||
| @@ -345,6 +345,20 @@ func (a *Agent) fireMessageAppended(m provider.Message) { | |||||
| 345 | // stops or an error occurs. Events are delivered via sink in order. | 345 | // stops or an error occurs. Events are delivered via sink in order. | ||
| 346 | // sink must not block the caller for long; buffer as needed. | 346 | // sink must not block the caller for long; buffer as needed. | ||
| 347 | func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error { | 347 | func (a *Agent) Prompt(ctx context.Context, text string, images []provider.ImageBlock, sink func(AgentEvent)) error { | ||
| 348 | + | return a.promptWithPrelude(ctx, text, images, nil, "", sink) | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | // PromptWithTool runs a host-requested tool after the user prompt and before | |||
| 352 | + | // the first model turn. The call and result are persisted as a matched pair. | |||
| 353 | + | // A cancelled execution still records its result, but does not call the model. | |||
| 354 | + | func (a *Agent) PromptWithTool(ctx context.Context, text string, call provider.ToolCallBlock, origin string, sink func(AgentEvent)) error { | |||
| 355 | + | if strings.TrimSpace(text) == "" || call.ID == "" || call.Name == "" || !json.Valid(call.Arguments) || !strings.HasPrefix(strings.TrimSpace(string(call.Arguments)), "{") { | |||
| 356 | + | return fmt.Errorf("tool prompt requires text, tool ID, name and JSON object arguments") | |||
| 357 | + | } | |||
| 358 | + | return a.promptWithPrelude(ctx, text, nil, &call, origin, sink) | |||
| 359 | + | } | |||
| 360 | + | ||||
| 361 | + | func (a *Agent) promptWithPrelude(ctx context.Context, text string, images []provider.ImageBlock, call *provider.ToolCallBlock, origin string, sink func(AgentEvent)) error { | |||
| 348 | if sink == nil { | 362 | if sink == nil { | ||
| 349 | sink = func(AgentEvent) {} | 363 | sink = func(AgentEvent) {} | ||
| 350 | } | 364 | } | ||
| @@ -368,6 +382,40 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image | |||||
| 368 | a.fireMessageAppended(user) | 382 | a.fireMessageAppended(user) | ||
| 369 | sink(EvUserMessage{Message: user}) | 383 | sink(EvUserMessage{Message: user}) | ||
| 370 | 384 | ||||
| 385 | + | if call != nil { | |||
| 386 | + | if err := ctx.Err(); err != nil { | |||
| 387 | + | return err | |||
| 388 | + | } | |||
| 389 | + | sink(EvToolCall{ID: call.ID, Name: call.Name, Args: call.Arguments}) | |||
| 390 | + | result := a.runOneTool(ctx, *call, sink) | |||
| 391 | + | assistant := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{*call}, Time: time.Now(), Meta: map[string]string{"origin_extension": origin, "synthetic_tool_call": "true"}} | |||
| 392 | + | tool := provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: call.ID, Content: result.Content, IsError: result.IsError}}, Time: time.Now()} | |||
| 393 | + | for _, name := range result.ActivateTools { | |||
| 394 | + | if _, err := a.Tools.Get(name); err == nil && !containsString(tool.AddedToolNames, name) { | |||
| 395 | + | tool.AddedToolNames = append(tool.AddedToolNames, name) | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | var mirror provider.Message | |||
| 399 | + | if a.Client != nil && (a.Client.Name() == "openai" || a.Client.Name() == "openai-codex") { | |||
| 400 | + | mirror = mirrorToolImagesAsUser(tool) | |||
| 401 | + | } | |||
| 402 | + | a.mu.Lock() | |||
| 403 | + | a.messages = append(a.messages, assistant, tool) | |||
| 404 | + | a.rev += 2 | |||
| 405 | + | if len(mirror.Content) > 0 { | |||
| 406 | + | a.messages = append(a.messages, mirror) | |||
| 407 | + | a.rev++ | |||
| 408 | + | } | |||
| 409 | + | a.mu.Unlock() | |||
| 410 | + | a.fireMessageAppended(assistant) | |||
| 411 | + | a.fireMessageAppended(tool) | |||
| 412 | + | if len(mirror.Content) > 0 { | |||
| 413 | + | a.fireMessageAppended(mirror) | |||
| 414 | + | } | |||
| 415 | + | if err := ctx.Err(); err != nil { | |||
| 416 | + | return err | |||
| 417 | + | } | |||
| 418 | + | } | |||
| 371 | return a.runLoop(ctx, sink) | 419 | return a.runLoop(ctx, sink) | ||
| 372 | } | 420 | } | ||
| 373 | 421 | ||||
A packages/core/tool_prompt_test.go +109 -0
| @@ -0,0 +1,109 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type preludeClient struct { | |
| 12 | + | messages []provider.Message | |
| 13 | + | calls int | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (c *preludeClient) Name() string { return "prelude" } | |
| 17 | + | func (c *preludeClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 18 | + | c.calls++ | |
| 19 | + | c.messages = append([]provider.Message(nil), req.Messages...) | |
| 20 | + | ch := make(chan provider.Event, 1) | |
| 21 | + | ch <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}} | |
| 22 | + | close(ch) | |
| 23 | + | return ch, nil | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestPromptWithToolPairsResultBeforeModelAndPersists(t *testing.T) { | |
| 27 | + | client := &preludeClient{} | |
| 28 | + | tool := &recordingTool{} | |
| 29 | + | a := NewAgent(client, "test", "", Registry{"echo": tool}) | |
| 30 | + | session, err := NewSession(t.TempDir(), t.TempDir(), "test", "test", "test") | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | a.OnMessageAppended = func(msg provider.Message) { | |
| 35 | + | if err := session.AppendMessage(msg); err != nil { | |
| 36 | + | t.Error(err) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | call := provider.ToolCallBlock{ID: "ext-prompt-1", Name: "echo", Arguments: json.RawMessage(`{"x":1}`)} | |
| 40 | + | var events []AgentEvent | |
| 41 | + | if err := a.PromptWithTool(context.Background(), "and push", call, "my-ext", func(ev AgentEvent) { events = append(events, ev) }); err != nil { | |
| 42 | + | t.Fatal(err) | |
| 43 | + | } | |
| 44 | + | if client.calls != 1 || len(client.messages) != 3 || client.messages[0].Role != provider.RoleUser || client.messages[1].Role != provider.RoleAssistant || client.messages[2].Role != provider.RoleTool { | |
| 45 | + | t.Fatalf("model input: calls=%d messages=%+v", client.calls, client.messages) | |
| 46 | + | } | |
| 47 | + | gotCall := client.messages[1].Content[0].(provider.ToolCallBlock) | |
| 48 | + | gotResult := client.messages[2].Content[0].(provider.ToolResultBlock) | |
| 49 | + | if gotCall.ID != gotResult.CallID || gotResult.IsError || client.messages[1].Meta["origin_extension"] != "my-ext" || string(tool.lastArgs) != string(call.Arguments) { | |
| 50 | + | t.Fatalf("call/result mismatch: %v %v", gotCall, gotResult) | |
| 51 | + | } | |
| 52 | + | if _, ok := events[1].(EvToolCall); !ok { | |
| 53 | + | t.Fatalf("missing call event: %T", events[1]) | |
| 54 | + | } | |
| 55 | + | if _, ok := events[2].(EvToolResult); !ok { | |
| 56 | + | t.Fatalf("missing result event: %T", events[2]) | |
| 57 | + | } | |
| 58 | + | path := session.Path | |
| 59 | + | if err := session.Close(); err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | reopened, msgs, err := OpenSession(path) | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | t.Cleanup(func() { _ = reopened.Close() }) | |
| 67 | + | if len(msgs) < 3 || msgs[1].Meta["origin_extension"] != "my-ext" || msgs[1].Meta["synthetic_tool_call"] != "true" || msgs[2].Content[0].(provider.ToolResultBlock).CallID != gotCall.ID { | |
| 68 | + | t.Fatalf("restored transcript: %+v", msgs) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestPromptWithToolCancellationKeepsPair(t *testing.T) { | |
| 73 | + | client := &preludeClient{} | |
| 74 | + | tool := &blockingTool{started: make(chan struct{}), release: make(chan struct{})} | |
| 75 | + | a := NewAgent(client, "test", "", Registry{"echo": tool}) | |
| 76 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 77 | + | done := make(chan error, 1) | |
| 78 | + | go func() { | |
| 79 | + | done <- a.PromptWithTool(ctx, "request", provider.ToolCallBlock{ID: "id", Name: "echo", Arguments: json.RawMessage(`{}`)}, "ext", nil) | |
| 80 | + | }() | |
| 81 | + | <-tool.started | |
| 82 | + | cancel() | |
| 83 | + | if err := <-done; err != context.Canceled { | |
| 84 | + | t.Fatalf("error = %v", err) | |
| 85 | + | } | |
| 86 | + | msgs := a.Messages() | |
| 87 | + | if client.calls != 0 || len(msgs) != 3 || msgs[1].Content[0].(provider.ToolCallBlock).ID != msgs[2].Content[0].(provider.ToolResultBlock).CallID || !msgs[2].Content[0].(provider.ToolResultBlock).IsError { | |
| 88 | + | t.Fatalf("cancelled transcript: calls=%d messages=%+v", client.calls, msgs) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestPromptWithToolDeniedAndInvalid(t *testing.T) { | |
| 93 | + | client := &preludeClient{} | |
| 94 | + | tool := &recordingTool{} | |
| 95 | + | a := NewAgent(client, "test", "", Registry{"echo": tool}) | |
| 96 | + | a.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { return false, "denied", nil } | |
| 97 | + | call := provider.ToolCallBlock{ID: "id", Name: "echo", Arguments: json.RawMessage(`{}`)} | |
| 98 | + | if err := a.PromptWithTool(context.Background(), "request", call, "ext", nil); err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | if tool.lastArgs != nil || !client.messages[2].Content[0].(provider.ToolResultBlock).IsError { | |
| 102 | + | t.Fatalf("guard bypassed: %+v", client.messages) | |
| 103 | + | } | |
| 104 | + | before := len(a.Messages()) | |
| 105 | + | call.Arguments = json.RawMessage(`[]`) | |
| 106 | + | if err := a.PromptWithTool(context.Background(), "request", call, "ext", nil); err == nil || len(a.Messages()) != before { | |
| 107 | + | t.Fatalf("invalid input mutated transcript: %v", err) | |
| 108 | + | } | |
| 109 | + | } | |
| @@ -0,0 +1,109 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type preludeClient struct { | |||
| 12 | + | messages []provider.Message | |||
| 13 | + | calls int | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (c *preludeClient) Name() string { return "prelude" } | |||
| 17 | + | func (c *preludeClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 18 | + | c.calls++ | |||
| 19 | + | c.messages = append([]provider.Message(nil), req.Messages...) | |||
| 20 | + | ch := make(chan provider.Event, 1) | |||
| 21 | + | ch <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}} | |||
| 22 | + | close(ch) | |||
| 23 | + | return ch, nil | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestPromptWithToolPairsResultBeforeModelAndPersists(t *testing.T) { | |||
| 27 | + | client := &preludeClient{} | |||
| 28 | + | tool := &recordingTool{} | |||
| 29 | + | a := NewAgent(client, "test", "", Registry{"echo": tool}) | |||
| 30 | + | session, err := NewSession(t.TempDir(), t.TempDir(), "test", "test", "test") | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | a.OnMessageAppended = func(msg provider.Message) { | |||
| 35 | + | if err := session.AppendMessage(msg); err != nil { | |||
| 36 | + | t.Error(err) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | call := provider.ToolCallBlock{ID: "ext-prompt-1", Name: "echo", Arguments: json.RawMessage(`{"x":1}`)} | |||
| 40 | + | var events []AgentEvent | |||
| 41 | + | if err := a.PromptWithTool(context.Background(), "and push", call, "my-ext", func(ev AgentEvent) { events = append(events, ev) }); err != nil { | |||
| 42 | + | t.Fatal(err) | |||
| 43 | + | } | |||
| 44 | + | if client.calls != 1 || len(client.messages) != 3 || client.messages[0].Role != provider.RoleUser || client.messages[1].Role != provider.RoleAssistant || client.messages[2].Role != provider.RoleTool { | |||
| 45 | + | t.Fatalf("model input: calls=%d messages=%+v", client.calls, client.messages) | |||
| 46 | + | } | |||
| 47 | + | gotCall := client.messages[1].Content[0].(provider.ToolCallBlock) | |||
| 48 | + | gotResult := client.messages[2].Content[0].(provider.ToolResultBlock) | |||
| 49 | + | if gotCall.ID != gotResult.CallID || gotResult.IsError || client.messages[1].Meta["origin_extension"] != "my-ext" || string(tool.lastArgs) != string(call.Arguments) { | |||
| 50 | + | t.Fatalf("call/result mismatch: %v %v", gotCall, gotResult) | |||
| 51 | + | } | |||
| 52 | + | if _, ok := events[1].(EvToolCall); !ok { | |||
| 53 | + | t.Fatalf("missing call event: %T", events[1]) | |||
| 54 | + | } | |||
| 55 | + | if _, ok := events[2].(EvToolResult); !ok { | |||
| 56 | + | t.Fatalf("missing result event: %T", events[2]) | |||
| 57 | + | } | |||
| 58 | + | path := session.Path | |||
| 59 | + | if err := session.Close(); err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | reopened, msgs, err := OpenSession(path) | |||
| 63 | + | if err != nil { | |||
| 64 | + | t.Fatal(err) | |||
| 65 | + | } | |||
| 66 | + | t.Cleanup(func() { _ = reopened.Close() }) | |||
| 67 | + | if len(msgs) < 3 || msgs[1].Meta["origin_extension"] != "my-ext" || msgs[1].Meta["synthetic_tool_call"] != "true" || msgs[2].Content[0].(provider.ToolResultBlock).CallID != gotCall.ID { | |||
| 68 | + | t.Fatalf("restored transcript: %+v", msgs) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestPromptWithToolCancellationKeepsPair(t *testing.T) { | |||
| 73 | + | client := &preludeClient{} | |||
| 74 | + | tool := &blockingTool{started: make(chan struct{}), release: make(chan struct{})} | |||
| 75 | + | a := NewAgent(client, "test", "", Registry{"echo": tool}) | |||
| 76 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 77 | + | done := make(chan error, 1) | |||
| 78 | + | go func() { | |||
| 79 | + | done <- a.PromptWithTool(ctx, "request", provider.ToolCallBlock{ID: "id", Name: "echo", Arguments: json.RawMessage(`{}`)}, "ext", nil) | |||
| 80 | + | }() | |||
| 81 | + | <-tool.started | |||
| 82 | + | cancel() | |||
| 83 | + | if err := <-done; err != context.Canceled { | |||
| 84 | + | t.Fatalf("error = %v", err) | |||
| 85 | + | } | |||
| 86 | + | msgs := a.Messages() | |||
| 87 | + | if client.calls != 0 || len(msgs) != 3 || msgs[1].Content[0].(provider.ToolCallBlock).ID != msgs[2].Content[0].(provider.ToolResultBlock).CallID || !msgs[2].Content[0].(provider.ToolResultBlock).IsError { | |||
| 88 | + | t.Fatalf("cancelled transcript: calls=%d messages=%+v", client.calls, msgs) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func TestPromptWithToolDeniedAndInvalid(t *testing.T) { | |||
| 93 | + | client := &preludeClient{} | |||
| 94 | + | tool := &recordingTool{} | |||
| 95 | + | a := NewAgent(client, "test", "", Registry{"echo": tool}) | |||
| 96 | + | a.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) { return false, "denied", nil } | |||
| 97 | + | call := provider.ToolCallBlock{ID: "id", Name: "echo", Arguments: json.RawMessage(`{}`)} | |||
| 98 | + | if err := a.PromptWithTool(context.Background(), "request", call, "ext", nil); err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | if tool.lastArgs != nil || !client.messages[2].Content[0].(provider.ToolResultBlock).IsError { | |||
| 102 | + | t.Fatalf("guard bypassed: %+v", client.messages) | |||
| 103 | + | } | |||
| 104 | + | before := len(a.Messages()) | |||
| 105 | + | call.Arguments = json.RawMessage(`[]`) | |||
| 106 | + | if err := a.PromptWithTool(context.Background(), "request", call, "ext", nil); err == nil || len(a.Messages()) != before { | |||
| 107 | + | t.Fatalf("invalid input mutated transcript: %v", err) | |||
| 108 | + | } | |||
| 109 | + | } | |||
M packages/provider/gemini.go +11 -0
| @@ -204,6 +204,17 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 204 | 204 | out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts}) | |
| 205 | 205 | case RoleAssistant: | |
| 206 | 206 | parts := convertGemAssistantParts(msg.Content, functionsEnabled) | |
| 207 | + | // Gemini 3 requires a thought signature on client-injected function | |
| 208 | + | // calls. Google's documented placeholder opts out of validation | |
| 209 | + | // for calls that were not generated by Gemini. Do not alter genuine | |
| 210 | + | // model responses or replace signatures obtained from the API. | |
| 211 | + | if strings.HasPrefix(strings.ToLower(m.ID), "gemini-3") && msg.Meta["synthetic_tool_call"] == "true" { | |
| 212 | + | for i := range parts { | |
| 213 | + | if parts[i].FunctionCall != nil && parts[i].ThoughtSignature == "" { | |
| 214 | + | parts[i].ThoughtSignature = "skip_thought_signature_validator" | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | } | |
| 207 | 218 | if len(parts) == 0 { | |
| 208 | 219 | continue | |
| 209 | 220 | } | |
| @@ -204,6 +204,17 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||||
| 204 | out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts}) | 204 | out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts}) | ||
| 205 | case RoleAssistant: | 205 | case RoleAssistant: | ||
| 206 | parts := convertGemAssistantParts(msg.Content, functionsEnabled) | 206 | parts := convertGemAssistantParts(msg.Content, functionsEnabled) | ||
| 207 | + | // Gemini 3 requires a thought signature on client-injected function | |||
| 208 | + | // calls. Google's documented placeholder opts out of validation | |||
| 209 | + | // for calls that were not generated by Gemini. Do not alter genuine | |||
| 210 | + | // model responses or replace signatures obtained from the API. | |||
| 211 | + | if strings.HasPrefix(strings.ToLower(m.ID), "gemini-3") && msg.Meta["synthetic_tool_call"] == "true" { | |||
| 212 | + | for i := range parts { | |||
| 213 | + | if parts[i].FunctionCall != nil && parts[i].ThoughtSignature == "" { | |||
| 214 | + | parts[i].ThoughtSignature = "skip_thought_signature_validator" | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 207 | if len(parts) == 0 { | 218 | if len(parts) == 0 { | ||
| 208 | continue | 219 | continue | ||
| 209 | } | 220 | } | ||
M packages/provider/gemini_test.go +30 -0
| @@ -592,6 +592,36 @@ func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) { | |||
| 592 | 592 | } | |
| 593 | 593 | } | |
| 594 | 594 | ||
| 595 | + | // TestGeminiSyntheticToolCallSignature checks the documented Gemini 3 | |
| 596 | + | // placeholder without changing model-generated calls or their signatures. | |
| 597 | + | func TestGeminiSyntheticToolCallSignature(t *testing.T) { | |
| 598 | + | client := NewGemini("key", "https://example.invalid").(*geminiClient) | |
| 599 | + | cases := []struct { | |
| 600 | + | name, model, signature, metadata, want string | |
| 601 | + | }{ | |
| 602 | + | {"synthetic Gemini 3", "gemini-3-flash-preview", "", "true", "skip_thought_signature_validator"}, | |
| 603 | + | {"synthetic Gemini 2.5", "gemini-2.5-pro", "", "true", ""}, | |
| 604 | + | {"genuine unsigned call", "gemini-3-flash-preview", "", "", ""}, | |
| 605 | + | {"preserve signed call", "gemini-3-flash-preview", "api-signature", "true", "api-signature"}, | |
| 606 | + | } | |
| 607 | + | for _, tc := range cases { | |
| 608 | + | t.Run(tc.name, func(t *testing.T) { | |
| 609 | + | call := ToolCallBlock{ID: "id", Name: "skill", Arguments: json.RawMessage(`{"name":"test"}`), ThoughtSignature: tc.signature} | |
| 610 | + | wire, _, err := client.buildRequest(Request{Model: tc.model, Messages: []Message{ | |
| 611 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "request"}}}, | |
| 612 | + | {Role: RoleAssistant, Content: []Content{call}, Meta: map[string]string{"synthetic_tool_call": tc.metadata}}, | |
| 613 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "id", Content: []Content{TextBlock{Text: "instructions"}}}}}, | |
| 614 | + | }}) | |
| 615 | + | if err != nil { | |
| 616 | + | t.Fatal(err) | |
| 617 | + | } | |
| 618 | + | if len(wire.Contents) != 3 || len(wire.Contents[1].Parts) != 1 || wire.Contents[1].Parts[0].FunctionCall == nil || wire.Contents[1].Parts[0].ThoughtSignature != tc.want || call.ThoughtSignature != tc.signature { | |
| 619 | + | t.Fatalf("wire = %+v, input call = %+v", wire.Contents, call) | |
| 620 | + | } | |
| 621 | + | }) | |
| 622 | + | } | |
| 623 | + | } | |
| 624 | + | ||
| 595 | 625 | // TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call | |
| 596 | 626 | // featuring a thoughtSignature in the SSE stream successfully extracts and | |
| 597 | 627 | // attaches the thought signature onto the final ToolCallBlock. | |
| @@ -592,6 +592,36 @@ func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) { | |||||
| 592 | } | 592 | } | ||
| 593 | } | 593 | } | ||
| 594 | 594 | ||||
| 595 | + | // TestGeminiSyntheticToolCallSignature checks the documented Gemini 3 | |||
| 596 | + | // placeholder without changing model-generated calls or their signatures. | |||
| 597 | + | func TestGeminiSyntheticToolCallSignature(t *testing.T) { | |||
| 598 | + | client := NewGemini("key", "https://example.invalid").(*geminiClient) | |||
| 599 | + | cases := []struct { | |||
| 600 | + | name, model, signature, metadata, want string | |||
| 601 | + | }{ | |||
| 602 | + | {"synthetic Gemini 3", "gemini-3-flash-preview", "", "true", "skip_thought_signature_validator"}, | |||
| 603 | + | {"synthetic Gemini 2.5", "gemini-2.5-pro", "", "true", ""}, | |||
| 604 | + | {"genuine unsigned call", "gemini-3-flash-preview", "", "", ""}, | |||
| 605 | + | {"preserve signed call", "gemini-3-flash-preview", "api-signature", "true", "api-signature"}, | |||
| 606 | + | } | |||
| 607 | + | for _, tc := range cases { | |||
| 608 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 609 | + | call := ToolCallBlock{ID: "id", Name: "skill", Arguments: json.RawMessage(`{"name":"test"}`), ThoughtSignature: tc.signature} | |||
| 610 | + | wire, _, err := client.buildRequest(Request{Model: tc.model, Messages: []Message{ | |||
| 611 | + | {Role: RoleUser, Content: []Content{TextBlock{Text: "request"}}}, | |||
| 612 | + | {Role: RoleAssistant, Content: []Content{call}, Meta: map[string]string{"synthetic_tool_call": tc.metadata}}, | |||
| 613 | + | {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "id", Content: []Content{TextBlock{Text: "instructions"}}}}}, | |||
| 614 | + | }}) | |||
| 615 | + | if err != nil { | |||
| 616 | + | t.Fatal(err) | |||
| 617 | + | } | |||
| 618 | + | if len(wire.Contents) != 3 || len(wire.Contents[1].Parts) != 1 || wire.Contents[1].Parts[0].FunctionCall == nil || wire.Contents[1].Parts[0].ThoughtSignature != tc.want || call.ThoughtSignature != tc.signature { | |||
| 619 | + | t.Fatalf("wire = %+v, input call = %+v", wire.Contents, call) | |||
| 620 | + | } | |||
| 621 | + | }) | |||
| 622 | + | } | |||
| 623 | + | } | |||
| 624 | + | ||||
| 595 | // TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call | 625 | // TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call | ||
| 596 | // featuring a thoughtSignature in the SSE stream successfully extracts and | 626 | // featuring a thoughtSignature in the SSE stream successfully extracts and | ||
| 597 | // attaches the thought signature onto the final ToolCallBlock. | 627 | // attaches the thought signature onto the final ToolCallBlock. | ||