detached c5883a4

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.
426426
427427 - `"prompt"` — submits `prompt` as a fresh user message; the agent
428428 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.
429439 - `"insert"` — inserts `insert` into the editor at the cursor without
430440 submitting.
431441 - `"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.
436446 `notify` frames or kicked off background work). zot doesn't change
437447 the UI in response.
438448
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+
439470 Example:
440471
441472 ```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 agent427 - `"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 without439 - `"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 styled441 - `"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 change446 `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 ```json472 ```json
M packages/agent/ext/ext.go +19 -1
@@ -255,8 +255,10 @@ type Panel struct {
255255 }
256256
257257 type Response struct {
258- Action string // "prompt", "insert", "display", "open_panel", "noop"
258+ Action string // "prompt", "tool_prompt", "insert", "display", "open_panel", "noop"
259259 Prompt string
260+ ToolName string
261+ ToolArgs json.RawMessage
260262 Insert string
261263 Display string
262264 OpenPanel *Panel
@@ -268,6 +270,20 @@ type Response struct {
268270 // typed and pressed enter).
269271 func Prompt(text string) Response { return Response{Action: "prompt", Prompt: text} }
270272
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+
271287 // Insert returns a Response that drops text into the editor at the
272288 // cursor without submitting.
273289 func Insert(text string) Response { return Response{Action: "insert", Insert: text} }
@@ -837,6 +853,8 @@ func (e *Extension) respond(id string, r Response) {
837853 ID: id,
838854 Action: r.Action,
839855 Prompt: r.Prompt,
856+ ToolName: r.ToolName,
857+ ToolArgs: r.ToolArgs,
840858 Insert: r.Insert,
841859 Display: r.Display,
842860 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 string259 Prompt string
260+ ToolName string
261+ ToolArgs json.RawMessage
260 Insert string262 Insert string
261 Display string263 Display string
262 OpenPanel *Panel264 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 the287 // 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 {
8484 }
8585
8686 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"`
9597 }
9698
9799 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 {
509509 // already appended to the agent transcript, avoiding a duplicate message.
510510 pendingCompactPrompt string
511511 pendingCompactImages []provider.ImageBlock
512+ pendingCompactTool *toolPromptRequest
512513 hasPendingCompactPrompt bool
513514 continueAfterCompact bool
514515
@@ -3145,6 +3146,18 @@ func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args str
31453146 return
31463147 }
31473148 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+ })
31483161 case "insert":
31493162 i.ed.Insert(resp.Insert)
31503163 i.invalidate()
@@ -5876,6 +5889,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
58765889 func (i *Interactive) clearPendingCompactTurnLocked() {
58775890 i.pendingCompactPrompt = ""
58785891 i.pendingCompactImages = nil
5892+ i.pendingCompactTool = nil
58795893 i.hasPendingCompactPrompt = false
58805894 i.continueAfterCompact = false
58815895 i.pendingPostCompactNote = ""
@@ -5934,6 +5948,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
59345948 // prompts typed during compaction remain in the host queue.
59355949 var next string
59365950 var nextImages []provider.ImageBlock
5951+ var nextTool *toolPromptRequest
59375952 var hasNext bool
59385953 var continueExisting bool
59395954
@@ -5988,6 +6003,8 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
59886003 case i.hasPendingCompactPrompt:
59896004 next = i.pendingCompactPrompt
59906005 nextImages = i.pendingCompactImages
6006+ nextTool = i.pendingCompactTool
6007+ i.pendingCompactTool = nil
59916008 i.pendingCompactPrompt = ""
59926009 i.pendingCompactImages = nil
59936010 i.hasPendingCompactPrompt = false
@@ -6014,7 +6031,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
60146031 if continueExisting {
60156032 i.startTurnRequest(p, "", nil, true)
60166033 } else {
6017- i.startTurnWithImages(p, next, nextImages)
6034+ i.startTurnWithPrelude(p, next, nextImages, nextTool)
60186035 }
60196036 }
60206037 }()
@@ -6152,10 +6169,23 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) {
61526169 }
61536170
61546171 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)
61566182 }
61576183
61586184 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) {
61596189 if i.agent == nil {
61606190 // Text startup pre cannot run without credentials; continue so
61616191 // deferred InitialInput (pre-fill or auto-submit) still applies.
@@ -6177,6 +6207,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
61776207 if needsPreCompact {
61786208 i.pendingCompactPrompt = prompt
61796209 i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
6210+ i.pendingCompactTool = tool
61806211 i.hasPendingCompactPrompt = true
61816212 i.statusErr = ""
61826213 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
62416272 var err error
62426273 if overflowRecoveryAttempted {
62436274 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()
62446279 } else {
62456280 err = i.agent.Prompt(ctx, prompt, images, sink)
62466281 }
@@ -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 string510 pendingCompactPrompt string
511 pendingCompactImages []provider.ImageBlock511 pendingCompactImages []provider.ImageBlock
512+ pendingCompactTool *toolPromptRequest
512 hasPendingCompactPrompt bool513 hasPendingCompactPrompt bool
513 continueAfterCompact bool514 continueAfterCompact bool
514 515
@@ -3145,6 +3146,18 @@ func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args str
3145 return3146 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 = nil5891 i.pendingCompactImages = nil
5892+ i.pendingCompactTool = nil
5879 i.hasPendingCompactPrompt = false5893 i.hasPendingCompactPrompt = false
5880 i.continueAfterCompact = false5894 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 string5949 var next string
5936 var nextImages []provider.ImageBlock5950 var nextImages []provider.ImageBlock
5951+ var nextTool *toolPromptRequest
5937 var hasNext bool5952 var hasNext bool
5938 var continueExisting bool5953 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.pendingCompactPrompt6004 next = i.pendingCompactPrompt
5990 nextImages = i.pendingCompactImages6005 nextImages = i.pendingCompactImages
6006+ nextTool = i.pendingCompactTool
6007+ i.pendingCompactTool = nil
5991 i.pendingCompactPrompt = ""6008 i.pendingCompactPrompt = ""
5992 i.pendingCompactImages = nil6009 i.pendingCompactImages = nil
5993 i.hasPendingCompactPrompt = false6010 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 so6190 // 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 = prompt6208 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 = true6211 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 error6272 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) {
345345 // stops or an error occurs. Events are delivered via sink in order.
346346 // sink must not block the caller for long; buffer as needed.
347347 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 {
348362 if sink == nil {
349363 sink = func(AgentEvent) {}
350364 }
@@ -368,6 +382,40 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image
368382 a.fireMessageAppended(user)
369383 sink(EvUserMessage{Message: user})
370384
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+ }
371419 return a.runLoop(ctx, sink)
372420 }
373421
@@ -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) {
204204 out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})
205205 case RoleAssistant:
206206 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+ }
207218 if len(parts) == 0 {
208219 continue
209220 }
@@ -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 continue219 continue
209 }220 }
M packages/provider/gemini_test.go +30 -0
@@ -592,6 +592,36 @@ func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) {
592592 }
593593 }
594594
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+
595625 // TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call
596626 // featuring a thoughtSignature in the SSE stream successfully extracts and
597627 // 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 call625 // TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call
596 // featuring a thoughtSignature in the SSE stream successfully extracts and626 // 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close