detached 056d214

Add thought signatures for Gemini tool calls

Gemini requires thought signatures to be retained in function call
parts.
commit
056d214230cbaad277dd8d725d065ca794b216a6
author
Eckhart Koeppen · · 2026-07-06 09:20 UTC
committer
Eckhart Koeppen · · 2026-07-16 05:46 UTC
parent
c98b3d2
signature
unsigned
3 files · +232 -15
M packages/provider/gemini.go +40 -12
@@ -82,7 +82,8 @@ type gemPart struct {
8282 FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"`
8383 // Thought: true marks a thought-summary part. Outgoing parts
8484 // from zot never set this; incoming chunks might.
85- Thought bool `json:"thought,omitempty"`
85+ Thought bool `json:"thought,omitempty"`
86+ ThoughtSignature string `json:"thoughtSignature,omitempty"`
8687 }
8788
8889 type gemContent struct {
@@ -288,6 +289,16 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
288289 var parts []gemPart
289290 for _, b := range blocks {
290291 switch v := b.(type) {
292+ case ReasoningBlock:
293+ // Vertex AI requires thought parts with their signature to be
294+ // replayed verbatim on follow-up turns.
295+ if v.Encrypted != "" || v.Summary != "" {
296+ parts = append(parts, gemPart{
297+ Text: v.Summary,
298+ Thought: true,
299+ ThoughtSignature: v.Encrypted,
300+ })
301+ }
291302 case TextBlock:
292303 if strings.TrimSpace(v.Text) == "" {
293304 continue
@@ -306,6 +317,7 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
306317 Name: v.Name,
307318 Args: args,
308319 },
320+ ThoughtSignature: v.ThoughtSignature,
309321 })
310322 }
311323 }
@@ -508,13 +520,16 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
508520 // candidate carries a list of parts (text or functionCall),
509521 // possibly accumulating across chunks.
510522 type blockEntry struct {
511- kind string // "text" | "image" | "tool_use"
512- textBuf strings.Builder
513- image *ImageBlock
514- imagePath string
515- toolID string
516- toolName string
517- toolArgs strings.Builder
523+ kind string // "text" | "image" | "tool_use" | "reasoning"
524+ textBuf strings.Builder
525+ image *ImageBlock
526+ imagePath string
527+ toolID string
528+ toolName string
529+ toolArgs strings.Builder
530+ toolThoughtSig string
531+ reasoningSummary string
532+ reasoningSignature string
518533 }
519534 var (
520535 blocks []*blockEntry
@@ -580,6 +595,11 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
580595 content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)})
581596 }
582597 }
598+ case "reasoning":
599+ content = append(content, ReasoningBlock{
600+ Summary: b.reasoningSummary,
601+ Encrypted: b.reasoningSignature,
602+ })
583603 case "tool_use":
584604 args := b.toolArgs.String()
585605 if args == "" || !json.Valid([]byte(args)) {
@@ -587,6 +607,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
587607 }
588608 content = append(content, ToolCallBlock{
589609 ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
610+ ThoughtSignature: b.toolThoughtSig,
590611 })
591612 }
592613 }
@@ -622,6 +643,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
622643 Text string `json:"text"`
623644 InlineData *gemInlineData `json:"inlineData"`
624645 Thought bool `json:"thought"`
646+ ThoughtSignature string `json:"thoughtSignature"`
625647 FunctionCall *gemFunctionCall `json:"functionCall"`
626648 FunctionCallID string `json:"id"`
627649 FunctionCallName string `json:"name"`
@@ -665,6 +687,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
665687 args = json.RawMessage("{}")
666688 }
667689 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
690+ t.toolThoughtSig = part.ThoughtSignature
668691 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
669692 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
670693 out <- EventToolEnd{ID: t.toolID}
@@ -674,10 +697,15 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
674697 continue
675698 }
676699 if part.Thought {
677- // Thinking summaries arrive as text parts
678- // with thought=true. Not surfaced to the
679- // user in v1; could be exposed via a
680- // future ReasoningBlock if useful.
700+ // Store thought parts as ReasoningBlocks so
701+ // Vertex AI can replay the thought_signature
702+ // on follow-up turns (required by the API).
703+ blocks = append(blocks, &blockEntry{
704+ kind: "reasoning",
705+ reasoningSummary: part.Text,
706+ reasoningSignature: part.ThoughtSignature,
707+ })
708+ currentText = nil
681709 continue
682710 }
683711 appendText(part.Text)
@@ -82,7 +82,8 @@ type gemPart struct {
82 FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"`82 FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"`
83 // Thought: true marks a thought-summary part. Outgoing parts83 // Thought: true marks a thought-summary part. Outgoing parts
84 // from zot never set this; incoming chunks might.84 // from zot never set this; incoming chunks might.
85- Thought bool `json:"thought,omitempty"`85+ Thought bool `json:"thought,omitempty"`
86+ ThoughtSignature string `json:"thoughtSignature,omitempty"`
86 }87 }
87 88
88 type gemContent struct {89 type gemContent struct {
@@ -288,6 +289,16 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
288 var parts []gemPart289 var parts []gemPart
289 for _, b := range blocks {290 for _, b := range blocks {
290 switch v := b.(type) {291 switch v := b.(type) {
292+ case ReasoningBlock:
293+ // Vertex AI requires thought parts with their signature to be
294+ // replayed verbatim on follow-up turns.
295+ if v.Encrypted != "" || v.Summary != "" {
296+ parts = append(parts, gemPart{
297+ Text: v.Summary,
298+ Thought: true,
299+ ThoughtSignature: v.Encrypted,
300+ })
301+ }
291 case TextBlock:302 case TextBlock:
292 if strings.TrimSpace(v.Text) == "" {303 if strings.TrimSpace(v.Text) == "" {
293 continue304 continue
@@ -306,6 +317,7 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
306 Name: v.Name,317 Name: v.Name,
307 Args: args,318 Args: args,
308 },319 },
320+ ThoughtSignature: v.ThoughtSignature,
309 })321 })
310 }322 }
311 }323 }
@@ -508,13 +520,16 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
508 // candidate carries a list of parts (text or functionCall),520 // candidate carries a list of parts (text or functionCall),
509 // possibly accumulating across chunks.521 // possibly accumulating across chunks.
510 type blockEntry struct {522 type blockEntry struct {
511- kind string // "text" | "image" | "tool_use"523+ kind string // "text" | "image" | "tool_use" | "reasoning"
512- textBuf strings.Builder524+ textBuf strings.Builder
513- image *ImageBlock525+ image *ImageBlock
514- imagePath string526+ imagePath string
515- toolID string527+ toolID string
516- toolName string528+ toolName string
517- toolArgs strings.Builder529+ toolArgs strings.Builder
530+ toolThoughtSig string
531+ reasoningSummary string
532+ reasoningSignature string
518 }533 }
519 var (534 var (
520 blocks []*blockEntry535 blocks []*blockEntry
@@ -580,6 +595,11 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
580 content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)})595 content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)})
581 }596 }
582 }597 }
598+ case "reasoning":
599+ content = append(content, ReasoningBlock{
600+ Summary: b.reasoningSummary,
601+ Encrypted: b.reasoningSignature,
602+ })
583 case "tool_use":603 case "tool_use":
584 args := b.toolArgs.String()604 args := b.toolArgs.String()
585 if args == "" || !json.Valid([]byte(args)) {605 if args == "" || !json.Valid([]byte(args)) {
@@ -587,6 +607,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
587 }607 }
588 content = append(content, ToolCallBlock{608 content = append(content, ToolCallBlock{
589 ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),609 ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
610+ ThoughtSignature: b.toolThoughtSig,
590 })611 })
591 }612 }
592 }613 }
@@ -622,6 +643,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
622 Text string `json:"text"`643 Text string `json:"text"`
623 InlineData *gemInlineData `json:"inlineData"`644 InlineData *gemInlineData `json:"inlineData"`
624 Thought bool `json:"thought"`645 Thought bool `json:"thought"`
646+ ThoughtSignature string `json:"thoughtSignature"`
625 FunctionCall *gemFunctionCall `json:"functionCall"`647 FunctionCall *gemFunctionCall `json:"functionCall"`
626 FunctionCallID string `json:"id"`648 FunctionCallID string `json:"id"`
627 FunctionCallName string `json:"name"`649 FunctionCallName string `json:"name"`
@@ -665,6 +687,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
665 args = json.RawMessage("{}")687 args = json.RawMessage("{}")
666 }688 }
667 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)689 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
690+ t.toolThoughtSig = part.ThoughtSignature
668 out <- EventToolStart{ID: t.toolID, Name: t.toolName}691 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
669 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}692 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
670 out <- EventToolEnd{ID: t.toolID}693 out <- EventToolEnd{ID: t.toolID}
@@ -674,10 +697,15 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
674 continue697 continue
675 }698 }
676 if part.Thought {699 if part.Thought {
677- // Thinking summaries arrive as text parts700+ // Store thought parts as ReasoningBlocks so
678- // with thought=true. Not surfaced to the701+ // Vertex AI can replay the thought_signature
679- // user in v1; could be exposed via a702+ // on follow-up turns (required by the API).
680- // future ReasoningBlock if useful.703+ blocks = append(blocks, &blockEntry{
704+ kind: "reasoning",
705+ reasoningSummary: part.Text,
706+ reasoningSignature: part.ThoughtSignature,
707+ })
708+ currentText = nil
681 continue709 continue
682 }710 }
683 appendText(part.Text)711 appendText(part.Text)
M packages/provider/gemini_test.go +186 -0
@@ -375,3 +375,189 @@ func TestDiscoverGoogle(t *testing.T) {
375375 t.Errorf("second model wrong: %+v", got[1])
376376 }
377377 }
378+
379+// TestSanitizeGeminiToolSchemaEmptyOrInvalid covers the pre-existing
380+// fallback behavior for empty/invalid input, to make sure the new logic
381+// didn't regress it.
382+func TestSanitizeGeminiToolSchemaEmptyOrInvalid(t *testing.T) {
383+ for _, in := range []json.RawMessage{nil, json.RawMessage(``), json.RawMessage(`not json`)} {
384+ out := sanitizeGeminiToolSchema(in)
385+ var got map[string]any
386+ if err := json.Unmarshal(out, &got); err != nil {
387+ t.Fatalf("fallback schema is not valid JSON: %v (%s)", err, out)
388+ }
389+ if got["type"] != "object" {
390+ t.Fatalf("want type=object fallback, got %+v", got)
391+ }
392+ if _, ok := got["properties"].(map[string]any); !ok {
393+ t.Fatalf("want properties object fallback, got %+v", got)
394+ }
395+ }
396+}
397+
398+// TestGeminiStreamReasoning confirms that when the Gemini SSE stream
399+// contains thought blocks with a signature, they are correctly mapped to
400+// ReasoningBlock structures inside the final assistant message.
401+func TestGeminiStreamReasoning(t *testing.T) {
402+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
403+ w.Header().Set("content-type", "text/event-stream")
404+ w.WriteHeader(http.StatusOK)
405+ fl, _ := w.(http.Flusher)
406+ write := func(s string) {
407+ _, _ = w.Write([]byte(s))
408+ if fl != nil {
409+ fl.Flush()
410+ }
411+ }
412+ // Write a thought part with signature, then text, then usage containing thoughtsTokenCount.
413+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Thinking hard","thought":true,"thoughtSignature":"sig123"}]}}]}` + "\n\n")
414+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Answer text"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":10}}` + "\n\n")
415+ }))
416+ defer srv.Close()
417+
418+ c := NewGemini("k", srv.URL)
419+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
420+ if err != nil {
421+ t.Fatal(err)
422+ }
423+
424+ var gotText string
425+ var done EventDone
426+ var usage Usage
427+ for ev := range evs {
428+ switch e := ev.(type) {
429+ case EventTextDelta:
430+ gotText += e.Delta
431+ case EventUsage:
432+ usage = e.Usage
433+ case EventDone:
434+ done = e
435+ }
436+ }
437+
438+ if gotText != "Answer text" {
439+ t.Fatalf("text=%q", gotText)
440+ }
441+ if usage.OutputTokens != 5 { // candidatesTokenCount (2) + thoughtsTokenCount (3)
442+ t.Fatalf("usage output tokens=%d, want 5", usage.OutputTokens)
443+ }
444+
445+ // Verify that the final message contains both the ReasoningBlock and the TextBlock
446+ msg := done.Message
447+ if len(msg.Content) != 2 {
448+ t.Fatalf("want 2 content blocks, got %d: %+v", len(msg.Content), msg.Content)
449+ }
450+
451+ rb, ok := msg.Content[0].(ReasoningBlock)
452+ if !ok {
453+ t.Fatalf("first block should be ReasoningBlock, got %T", msg.Content[0])
454+ }
455+ if rb.Summary != "Thinking hard" || rb.Encrypted != "sig123" {
456+ t.Fatalf("invalid ReasoningBlock: %+v", rb)
457+ }
458+
459+ tb, ok := msg.Content[1].(TextBlock)
460+ if !ok {
461+ t.Fatalf("second block should be TextBlock, got %T", msg.Content[1])
462+ }
463+ if tb.Text != "Answer text" {
464+ t.Fatalf("invalid TextBlock: %+v", tb)
465+ }
466+}
467+
468+// TestGeminiBuildRequestWithReasoningReplay verifies that when zot sends a
469+// historical assistant message containing a ReasoningBlock, buildRequest
470+// serializes it into the verbatim Gemini "thought: true" wire representation
471+// with its corresponding thoughtSignature.
472+func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) {
473+ c := NewGemini("k", "https://example.invalid").(*geminiClient)
474+ wire, _, err := c.buildRequest(Request{
475+ Model: "gemini-2.5-pro",
476+ Messages: []Message{
477+ {Role: RoleUser, Content: []Content{TextBlock{Text: "Why is the sky blue?"}}},
478+ {Role: RoleAssistant, Content: []Content{
479+ ReasoningBlock{Summary: "Evaluating Rayleigh scattering", Encrypted: "sky-sig-999"},
480+ TextBlock{Text: "It scatters blue light more than other colors."},
481+ }},
482+ },
483+ })
484+ if err != nil {
485+ t.Fatal(err)
486+ }
487+
488+ if len(wire.Contents) != 2 {
489+ t.Fatalf("want 2 messages in wire, got %d", len(wire.Contents))
490+ }
491+
492+ assistant := wire.Contents[1]
493+ if assistant.Role != "model" {
494+ t.Fatalf("assistant role should be 'model', got %q", assistant.Role)
495+ }
496+
497+ if len(assistant.Parts) != 2 {
498+ t.Fatalf("want 2 parts, got %d: %+v", len(assistant.Parts), assistant.Parts)
499+ }
500+
501+ // Check the serialized ReasoningBlock
502+ p0 := assistant.Parts[0]
503+ if !p0.Thought {
504+ t.Errorf("part 0: expected Thought=true, got %+v", p0)
505+ }
506+ if p0.Text != "Evaluating Rayleigh scattering" {
507+ t.Errorf("part 0: expected text to match summary, got %q", p0.Text)
508+ }
509+ if p0.ThoughtSignature != "sky-sig-999" {
510+ t.Errorf("part 0: expected thought signature 'sky-sig-999', got %q", p0.ThoughtSignature)
511+ }
512+
513+ // Check the serialized TextBlock
514+ p1 := assistant.Parts[1]
515+ if p1.Thought {
516+ t.Errorf("part 1: expected Thought=false, got %+v", p1)
517+ }
518+ if p1.Text != "It scatters blue light more than other colors." {
519+ t.Errorf("part 1: expected text to match, got %q", p1.Text)
520+ }
521+}
522+
523+// TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call
524+// featuring a thoughtSignature in the SSE stream successfully extracts and
525+// attaches the thought signature onto the final ToolCallBlock.
526+func TestGeminiStreamToolCallWithThoughtSignature(t *testing.T) {
527+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
528+ w.Header().Set("content-type", "text/event-stream")
529+ w.WriteHeader(http.StatusOK)
530+ _, _ = w.Write([]byte("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig-456"}]},"finishReason":"STOP"}]}` + "\n\n"))
531+ }))
532+ defer srv.Close()
533+
534+ c := NewGemini("k", srv.URL)
535+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
536+ if err != nil {
537+ t.Fatal(err)
538+ }
539+
540+ var done EventDone
541+ for ev := range evs {
542+ if e, ok := ev.(EventDone); ok {
543+ done = e
544+ }
545+ }
546+
547+ if len(done.Message.Content) != 1 {
548+ t.Fatalf("want 1 content block, got %d", len(done.Message.Content))
549+ }
550+
551+ tc, ok := done.Message.Content[0].(ToolCallBlock)
552+ if !ok {
553+ t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0])
554+ }
555+
556+ if tc.Name != "read" {
557+ t.Fatalf("tool name=%q, want 'read'", tc.Name)
558+ }
559+
560+ if tc.ThoughtSignature != "tool-sig-456" {
561+ t.Fatalf("tool block thought signature=%q, want 'tool-sig-456'", tc.ThoughtSignature)
562+ }
563+}
@@ -375,3 +375,189 @@ func TestDiscoverGoogle(t *testing.T) {
375 t.Errorf("second model wrong: %+v", got[1])375 t.Errorf("second model wrong: %+v", got[1])
376 }376 }
377 }377 }
378+
379+// TestSanitizeGeminiToolSchemaEmptyOrInvalid covers the pre-existing
380+// fallback behavior for empty/invalid input, to make sure the new logic
381+// didn't regress it.
382+func TestSanitizeGeminiToolSchemaEmptyOrInvalid(t *testing.T) {
383+ for _, in := range []json.RawMessage{nil, json.RawMessage(``), json.RawMessage(`not json`)} {
384+ out := sanitizeGeminiToolSchema(in)
385+ var got map[string]any
386+ if err := json.Unmarshal(out, &got); err != nil {
387+ t.Fatalf("fallback schema is not valid JSON: %v (%s)", err, out)
388+ }
389+ if got["type"] != "object" {
390+ t.Fatalf("want type=object fallback, got %+v", got)
391+ }
392+ if _, ok := got["properties"].(map[string]any); !ok {
393+ t.Fatalf("want properties object fallback, got %+v", got)
394+ }
395+ }
396+}
397+
398+// TestGeminiStreamReasoning confirms that when the Gemini SSE stream
399+// contains thought blocks with a signature, they are correctly mapped to
400+// ReasoningBlock structures inside the final assistant message.
401+func TestGeminiStreamReasoning(t *testing.T) {
402+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
403+ w.Header().Set("content-type", "text/event-stream")
404+ w.WriteHeader(http.StatusOK)
405+ fl, _ := w.(http.Flusher)
406+ write := func(s string) {
407+ _, _ = w.Write([]byte(s))
408+ if fl != nil {
409+ fl.Flush()
410+ }
411+ }
412+ // Write a thought part with signature, then text, then usage containing thoughtsTokenCount.
413+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Thinking hard","thought":true,"thoughtSignature":"sig123"}]}}]}` + "\n\n")
414+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Answer text"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":10}}` + "\n\n")
415+ }))
416+ defer srv.Close()
417+
418+ c := NewGemini("k", srv.URL)
419+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
420+ if err != nil {
421+ t.Fatal(err)
422+ }
423+
424+ var gotText string
425+ var done EventDone
426+ var usage Usage
427+ for ev := range evs {
428+ switch e := ev.(type) {
429+ case EventTextDelta:
430+ gotText += e.Delta
431+ case EventUsage:
432+ usage = e.Usage
433+ case EventDone:
434+ done = e
435+ }
436+ }
437+
438+ if gotText != "Answer text" {
439+ t.Fatalf("text=%q", gotText)
440+ }
441+ if usage.OutputTokens != 5 { // candidatesTokenCount (2) + thoughtsTokenCount (3)
442+ t.Fatalf("usage output tokens=%d, want 5", usage.OutputTokens)
443+ }
444+
445+ // Verify that the final message contains both the ReasoningBlock and the TextBlock
446+ msg := done.Message
447+ if len(msg.Content) != 2 {
448+ t.Fatalf("want 2 content blocks, got %d: %+v", len(msg.Content), msg.Content)
449+ }
450+
451+ rb, ok := msg.Content[0].(ReasoningBlock)
452+ if !ok {
453+ t.Fatalf("first block should be ReasoningBlock, got %T", msg.Content[0])
454+ }
455+ if rb.Summary != "Thinking hard" || rb.Encrypted != "sig123" {
456+ t.Fatalf("invalid ReasoningBlock: %+v", rb)
457+ }
458+
459+ tb, ok := msg.Content[1].(TextBlock)
460+ if !ok {
461+ t.Fatalf("second block should be TextBlock, got %T", msg.Content[1])
462+ }
463+ if tb.Text != "Answer text" {
464+ t.Fatalf("invalid TextBlock: %+v", tb)
465+ }
466+}
467+
468+// TestGeminiBuildRequestWithReasoningReplay verifies that when zot sends a
469+// historical assistant message containing a ReasoningBlock, buildRequest
470+// serializes it into the verbatim Gemini "thought: true" wire representation
471+// with its corresponding thoughtSignature.
472+func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) {
473+ c := NewGemini("k", "https://example.invalid").(*geminiClient)
474+ wire, _, err := c.buildRequest(Request{
475+ Model: "gemini-2.5-pro",
476+ Messages: []Message{
477+ {Role: RoleUser, Content: []Content{TextBlock{Text: "Why is the sky blue?"}}},
478+ {Role: RoleAssistant, Content: []Content{
479+ ReasoningBlock{Summary: "Evaluating Rayleigh scattering", Encrypted: "sky-sig-999"},
480+ TextBlock{Text: "It scatters blue light more than other colors."},
481+ }},
482+ },
483+ })
484+ if err != nil {
485+ t.Fatal(err)
486+ }
487+
488+ if len(wire.Contents) != 2 {
489+ t.Fatalf("want 2 messages in wire, got %d", len(wire.Contents))
490+ }
491+
492+ assistant := wire.Contents[1]
493+ if assistant.Role != "model" {
494+ t.Fatalf("assistant role should be 'model', got %q", assistant.Role)
495+ }
496+
497+ if len(assistant.Parts) != 2 {
498+ t.Fatalf("want 2 parts, got %d: %+v", len(assistant.Parts), assistant.Parts)
499+ }
500+
501+ // Check the serialized ReasoningBlock
502+ p0 := assistant.Parts[0]
503+ if !p0.Thought {
504+ t.Errorf("part 0: expected Thought=true, got %+v", p0)
505+ }
506+ if p0.Text != "Evaluating Rayleigh scattering" {
507+ t.Errorf("part 0: expected text to match summary, got %q", p0.Text)
508+ }
509+ if p0.ThoughtSignature != "sky-sig-999" {
510+ t.Errorf("part 0: expected thought signature 'sky-sig-999', got %q", p0.ThoughtSignature)
511+ }
512+
513+ // Check the serialized TextBlock
514+ p1 := assistant.Parts[1]
515+ if p1.Thought {
516+ t.Errorf("part 1: expected Thought=false, got %+v", p1)
517+ }
518+ if p1.Text != "It scatters blue light more than other colors." {
519+ t.Errorf("part 1: expected text to match, got %q", p1.Text)
520+ }
521+}
522+
523+// TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call
524+// featuring a thoughtSignature in the SSE stream successfully extracts and
525+// attaches the thought signature onto the final ToolCallBlock.
526+func TestGeminiStreamToolCallWithThoughtSignature(t *testing.T) {
527+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
528+ w.Header().Set("content-type", "text/event-stream")
529+ w.WriteHeader(http.StatusOK)
530+ _, _ = w.Write([]byte("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig-456"}]},"finishReason":"STOP"}]}` + "\n\n"))
531+ }))
532+ defer srv.Close()
533+
534+ c := NewGemini("k", srv.URL)
535+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
536+ if err != nil {
537+ t.Fatal(err)
538+ }
539+
540+ var done EventDone
541+ for ev := range evs {
542+ if e, ok := ev.(EventDone); ok {
543+ done = e
544+ }
545+ }
546+
547+ if len(done.Message.Content) != 1 {
548+ t.Fatalf("want 1 content block, got %d", len(done.Message.Content))
549+ }
550+
551+ tc, ok := done.Message.Content[0].(ToolCallBlock)
552+ if !ok {
553+ t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0])
554+ }
555+
556+ if tc.Name != "read" {
557+ t.Fatalf("tool name=%q, want 'read'", tc.Name)
558+ }
559+
560+ if tc.ThoughtSignature != "tool-sig-456" {
561+ t.Fatalf("tool block thought signature=%q, want 'tool-sig-456'", tc.ThoughtSignature)
562+ }
563+}
M packages/provider/provider.go +6 -3
@@ -43,9 +43,12 @@ func (ImageBlock) isContent() {}
4343
4444 // ToolCallBlock is an assistant-issued call to a tool.
4545 type ToolCallBlock struct {
46- ID string `json:"id"`
47- Name string `json:"name"`
48- Arguments json.RawMessage `json:"arguments"`
46+ ID string `json:"id"`
47+ Name string `json:"name"`
48+ Arguments json.RawMessage `json:"arguments"`
49+ // ThoughtSignature carries the Vertex AI thought_signature that must
50+ // be replayed on the functionCall part in follow-up turns.
51+ ThoughtSignature string `json:"thought_signature,omitempty"`
4952 }
5053
5154 func (ToolCallBlock) isContent() {}
@@ -43,9 +43,12 @@ func (ImageBlock) isContent() {}
43 43
44 // ToolCallBlock is an assistant-issued call to a tool.44 // ToolCallBlock is an assistant-issued call to a tool.
45 type ToolCallBlock struct {45 type ToolCallBlock struct {
46- ID string `json:"id"`46+ ID string `json:"id"`
47- Name string `json:"name"`47+ Name string `json:"name"`
48- Arguments json.RawMessage `json:"arguments"`48+ Arguments json.RawMessage `json:"arguments"`
49+ // ThoughtSignature carries the Vertex AI thought_signature that must
50+ // be replayed on the functionCall part in follow-up turns.
51+ ThoughtSignature string `json:"thought_signature,omitempty"`
49 }52 }
50 53
51 func (ToolCallBlock) isContent() {}54 func (ToolCallBlock) isContent() {}

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