detached 24f1547

Merge pull request #84 from ekoeppen/gemini-thought-sig

Add thought signatures for Gemini tool calls
commit
24f1547a58a3479f5046180c784d9637e3a5aee9
author
Patric Eckhart · · 2026-07-16 09:19 UTC
committer
GitHub · · 2026-07-16 09:19 UTC
merge of
c98b3d2 + d757bae · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.88
5 files · +434 -58
M packages/core/core_test.go +58 -0
@@ -46,6 +46,64 @@ func TestSessionRoundTrip(t *testing.T) {
4646 }
4747 }
4848
49+func TestSessionRoundTripPreservesThoughtSignatures(t *testing.T) {
50+ dir := t.TempDir()
51+ sess, err := NewSession(dir, "/tmp/project", "google", "gemini-3-flash", "test")
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ msg := provider.Message{
56+ Role: provider.RoleAssistant,
57+ Content: []provider.Content{
58+ provider.ReasoningBlock{Summary: "thinking"},
59+ provider.TextBlock{Text: "answer", ThoughtSignature: "text-sig"},
60+ provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"},
61+ provider.ToolCallBlock{
62+ ID: "call-1",
63+ Name: "read",
64+ Arguments: []byte(`{"path":"a"}`),
65+ ThoughtSignature: "tool-sig",
66+ },
67+ },
68+ }
69+ if err := sess.AppendMessage(msg); err != nil {
70+ t.Fatal(err)
71+ }
72+ if err := sess.AppendMessage(provider.Message{
73+ Role: provider.RoleTool,
74+ Content: []provider.Content{provider.ToolResultBlock{
75+ CallID: "call-1",
76+ Content: []provider.Content{provider.TextBlock{Text: "result"}},
77+ }},
78+ }); err != nil {
79+ t.Fatal(err)
80+ }
81+ if err := sess.Close(); err != nil {
82+ t.Fatal(err)
83+ }
84+
85+ reopened, msgs, err := OpenSession(sess.Path)
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ t.Cleanup(func() { _ = reopened.Close() })
90+ if len(msgs) != 2 || len(msgs[0].Content) != 4 {
91+ t.Fatalf("round trip content = %+v", msgs)
92+ }
93+ if rb, ok := msgs[0].Content[0].(provider.ReasoningBlock); !ok || rb.Summary != "thinking" {
94+ t.Fatalf("reasoning block = %#v", msgs[0].Content[0])
95+ }
96+ if tb, ok := msgs[0].Content[1].(provider.TextBlock); !ok || tb.ThoughtSignature != "text-sig" {
97+ t.Fatalf("text block = %#v", msgs[0].Content[1])
98+ }
99+ if ib, ok := msgs[0].Content[2].(provider.ImageBlock); !ok || ib.ThoughtSignature != "image-sig" {
100+ t.Fatalf("image block = %#v", msgs[0].Content[2])
101+ }
102+ if tc, ok := msgs[0].Content[3].(provider.ToolCallBlock); !ok || tc.ThoughtSignature != "tool-sig" {
103+ t.Fatalf("tool call block = %#v", msgs[0].Content[3])
104+ }
105+}
106+
49107 // TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's
50108 // session-persistence fallback works: NewSessionAtPath creates the
51109 // file (and parent dirs) at the exact path the caller chose,
@@ -46,6 +46,64 @@ func TestSessionRoundTrip(t *testing.T) {
46 }46 }
47 }47 }
48 48
49+func TestSessionRoundTripPreservesThoughtSignatures(t *testing.T) {
50+ dir := t.TempDir()
51+ sess, err := NewSession(dir, "/tmp/project", "google", "gemini-3-flash", "test")
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ msg := provider.Message{
56+ Role: provider.RoleAssistant,
57+ Content: []provider.Content{
58+ provider.ReasoningBlock{Summary: "thinking"},
59+ provider.TextBlock{Text: "answer", ThoughtSignature: "text-sig"},
60+ provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"},
61+ provider.ToolCallBlock{
62+ ID: "call-1",
63+ Name: "read",
64+ Arguments: []byte(`{"path":"a"}`),
65+ ThoughtSignature: "tool-sig",
66+ },
67+ },
68+ }
69+ if err := sess.AppendMessage(msg); err != nil {
70+ t.Fatal(err)
71+ }
72+ if err := sess.AppendMessage(provider.Message{
73+ Role: provider.RoleTool,
74+ Content: []provider.Content{provider.ToolResultBlock{
75+ CallID: "call-1",
76+ Content: []provider.Content{provider.TextBlock{Text: "result"}},
77+ }},
78+ }); err != nil {
79+ t.Fatal(err)
80+ }
81+ if err := sess.Close(); err != nil {
82+ t.Fatal(err)
83+ }
84+
85+ reopened, msgs, err := OpenSession(sess.Path)
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ t.Cleanup(func() { _ = reopened.Close() })
90+ if len(msgs) != 2 || len(msgs[0].Content) != 4 {
91+ t.Fatalf("round trip content = %+v", msgs)
92+ }
93+ if rb, ok := msgs[0].Content[0].(provider.ReasoningBlock); !ok || rb.Summary != "thinking" {
94+ t.Fatalf("reasoning block = %#v", msgs[0].Content[0])
95+ }
96+ if tb, ok := msgs[0].Content[1].(provider.TextBlock); !ok || tb.ThoughtSignature != "text-sig" {
97+ t.Fatalf("text block = %#v", msgs[0].Content[1])
98+ }
99+ if ib, ok := msgs[0].Content[2].(provider.ImageBlock); !ok || ib.ThoughtSignature != "image-sig" {
100+ t.Fatalf("image block = %#v", msgs[0].Content[2])
101+ }
102+ if tc, ok := msgs[0].Content[3].(provider.ToolCallBlock); !ok || tc.ThoughtSignature != "tool-sig" {
103+ t.Fatalf("tool call block = %#v", msgs[0].Content[3])
104+ }
105+}
106+
49 // TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's107 // TestNewSessionAtPathCreatesAtExplicitPath proves the swarm child's
50 // session-persistence fallback works: NewSessionAtPath creates the108 // session-persistence fallback works: NewSessionAtPath creates the
51 // file (and parent dirs) at the exact path the caller chose,109 // file (and parent dirs) at the exact path the caller chose,
M packages/core/session.go +30 -16
@@ -820,15 +820,16 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
820820 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta}
821821 for _, raw := range row.Content {
822822 var head struct {
823- Text string `json:"text"`
824- MimeType string `json:"mime_type"`
825- Data []byte `json:"data"`
826- ID string `json:"id"`
827- Name string `json:"name"`
828- CallID string `json:"call_id"`
829- ReasoningID string `json:"reasoning_id"`
830- Summary string `json:"summary"`
831- Encrypted string `json:"encrypted_content"`
823+ Text string `json:"text"`
824+ MimeType string `json:"mime_type"`
825+ Data []byte `json:"data"`
826+ ID string `json:"id"`
827+ Name string `json:"name"`
828+ CallID string `json:"call_id"`
829+ ReasoningID string `json:"reasoning_id"`
830+ Summary string `json:"summary"`
831+ Encrypted string `json:"encrypted_content"`
832+ ThoughtSignature string `json:"thought_signature"`
832833 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
833834 }
834835 if err := json.Unmarshal(raw, &head); err != nil {
@@ -836,7 +837,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
836837 }
837838 // Discriminate by presence of fields.
838839 switch {
839- case head.ReasoningID != "" || head.Encrypted != "":
840+ case head.ReasoningID != "" || head.Summary != "" || head.Encrypted != "":
840841 msg.Content = append(msg.Content, provider.ReasoningBlock{
841842 ID: head.ReasoningID,
842843 Summary: head.Summary,
@@ -844,12 +845,18 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
844845 })
845846 case head.Name != "" && head.ID != "":
846847 var tc struct {
847- ID string `json:"id"`
848- Name string `json:"name"`
849- Arguments json.RawMessage `json:"arguments"`
848+ ID string `json:"id"`
849+ Name string `json:"name"`
850+ Arguments json.RawMessage `json:"arguments"`
851+ ThoughtSignature string `json:"thought_signature"`
850852 }
851853 _ = json.Unmarshal(raw, &tc)
852- msg.Content = append(msg.Content, provider.ToolCallBlock{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments})
854+ msg.Content = append(msg.Content, provider.ToolCallBlock{
855+ ID: tc.ID,
856+ Name: tc.Name,
857+ Arguments: tc.Arguments,
858+ ThoughtSignature: tc.ThoughtSignature,
859+ })
853860 case head.CallID != "":
854861 var tr struct {
855862 CallID string `json:"call_id"`
@@ -873,9 +880,16 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
873880 }
874881 msg.Content = append(msg.Content, block)
875882 case head.MimeType != "":
876- msg.Content = append(msg.Content, provider.ImageBlock{MimeType: head.MimeType, Data: head.Data})
883+ msg.Content = append(msg.Content, provider.ImageBlock{
884+ MimeType: head.MimeType,
885+ Data: head.Data,
886+ ThoughtSignature: head.ThoughtSignature,
887+ })
877888 default:
878- msg.Content = append(msg.Content, provider.TextBlock{Text: head.Text})
889+ msg.Content = append(msg.Content, provider.TextBlock{
890+ Text: head.Text,
891+ ThoughtSignature: head.ThoughtSignature,
892+ })
879893 }
880894 }
881895 return msg, nil
@@ -820,15 +820,16 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
820 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta}820 msg := provider.Message{Role: row.Role, Time: row.Time, Meta: row.Meta}
821 for _, raw := range row.Content {821 for _, raw := range row.Content {
822 var head struct {822 var head struct {
823- Text string `json:"text"`823+ Text string `json:"text"`
824- MimeType string `json:"mime_type"`824+ MimeType string `json:"mime_type"`
825- Data []byte `json:"data"`825+ Data []byte `json:"data"`
826- ID string `json:"id"`826+ ID string `json:"id"`
827- Name string `json:"name"`827+ Name string `json:"name"`
828- CallID string `json:"call_id"`828+ CallID string `json:"call_id"`
829- ReasoningID string `json:"reasoning_id"`829+ ReasoningID string `json:"reasoning_id"`
830- Summary string `json:"summary"`830+ Summary string `json:"summary"`
831- Encrypted string `json:"encrypted_content"`831+ Encrypted string `json:"encrypted_content"`
832+ ThoughtSignature string `json:"thought_signature"`
832 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError833 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
833 }834 }
834 if err := json.Unmarshal(raw, &head); err != nil {835 if err := json.Unmarshal(raw, &head); err != nil {
@@ -836,7 +837,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
836 }837 }
837 // Discriminate by presence of fields.838 // Discriminate by presence of fields.
838 switch {839 switch {
839- case head.ReasoningID != "" || head.Encrypted != "":840+ case head.ReasoningID != "" || head.Summary != "" || head.Encrypted != "":
840 msg.Content = append(msg.Content, provider.ReasoningBlock{841 msg.Content = append(msg.Content, provider.ReasoningBlock{
841 ID: head.ReasoningID,842 ID: head.ReasoningID,
842 Summary: head.Summary,843 Summary: head.Summary,
@@ -844,12 +845,18 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
844 })845 })
845 case head.Name != "" && head.ID != "":846 case head.Name != "" && head.ID != "":
846 var tc struct {847 var tc struct {
847- ID string `json:"id"`848+ ID string `json:"id"`
848- Name string `json:"name"`849+ Name string `json:"name"`
849- Arguments json.RawMessage `json:"arguments"`850+ Arguments json.RawMessage `json:"arguments"`
851+ ThoughtSignature string `json:"thought_signature"`
850 }852 }
851 _ = json.Unmarshal(raw, &tc)853 _ = json.Unmarshal(raw, &tc)
852- msg.Content = append(msg.Content, provider.ToolCallBlock{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments})854+ msg.Content = append(msg.Content, provider.ToolCallBlock{
855+ ID: tc.ID,
856+ Name: tc.Name,
857+ Arguments: tc.Arguments,
858+ ThoughtSignature: tc.ThoughtSignature,
859+ })
853 case head.CallID != "":860 case head.CallID != "":
854 var tr struct {861 var tr struct {
855 CallID string `json:"call_id"`862 CallID string `json:"call_id"`
@@ -873,9 +880,16 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
873 }880 }
874 msg.Content = append(msg.Content, block)881 msg.Content = append(msg.Content, block)
875 case head.MimeType != "":882 case head.MimeType != "":
876- msg.Content = append(msg.Content, provider.ImageBlock{MimeType: head.MimeType, Data: head.Data})883+ msg.Content = append(msg.Content, provider.ImageBlock{
884+ MimeType: head.MimeType,
885+ Data: head.Data,
886+ ThoughtSignature: head.ThoughtSignature,
887+ })
877 default:888 default:
878- msg.Content = append(msg.Content, provider.TextBlock{Text: head.Text})889+ msg.Content = append(msg.Content, provider.TextBlock{
890+ Text: head.Text,
891+ ThoughtSignature: head.ThoughtSignature,
892+ })
879 }893 }
880 }894 }
881 return msg, nil895 return msg, nil
M packages/provider/gemini.go +82 -36
@@ -80,9 +80,10 @@ type gemPart struct {
8080 InlineData *gemInlineData `json:"inlineData,omitempty"`
8181 FunctionCall *gemFunctionCall `json:"functionCall,omitempty"`
8282 FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"`
83- // Thought: true marks a thought-summary part. Outgoing parts
84- // from zot never set this; incoming chunks might.
85- Thought bool `json:"thought,omitempty"`
83+ // Thought marks a thought-summary part. ThoughtSignature is opaque
84+ // provider state that must be replayed on the same part.
85+ Thought bool `json:"thought,omitempty"`
86+ ThoughtSignature string `json:"thoughtSignature,omitempty"`
8687 }
8788
8889 type gemContent struct {
@@ -288,11 +289,27 @@ 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+ if v.Encrypted != "" || v.Summary != "" {
294+ parts = append(parts, gemPart{
295+ Text: v.Summary,
296+ Thought: true,
297+ ThoughtSignature: v.Encrypted,
298+ })
299+ }
291300 case TextBlock:
292301 if strings.TrimSpace(v.Text) == "" {
293302 continue
294303 }
295- parts = append(parts, gemPart{Text: v.Text})
304+ parts = append(parts, gemPart{Text: v.Text, ThoughtSignature: v.ThoughtSignature})
305+ case ImageBlock:
306+ parts = append(parts, gemPart{
307+ InlineData: &gemInlineData{
308+ MimeType: v.MimeType,
309+ Data: base64.StdEncoding.EncodeToString(v.Data),
310+ },
311+ ThoughtSignature: v.ThoughtSignature,
312+ })
296313 case ToolCallBlock:
297314 if !functionsEnabled {
298315 continue
@@ -306,6 +323,7 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
306323 Name: v.Name,
307324 Args: args,
308325 },
326+ ThoughtSignature: v.ThoughtSignature,
309327 })
310328 }
311329 }
@@ -508,41 +526,54 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
508526 // candidate carries a list of parts (text or functionCall),
509527 // possibly accumulating across chunks.
510528 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
529+ kind string // "text" | "image" | "tool_use" | "reasoning"
530+ textBuf strings.Builder
531+ thoughtSignature string
532+ image *ImageBlock
533+ imagePath string
534+ toolID string
535+ toolName string
536+ toolArgs strings.Builder
518537 }
519538 var (
520- blocks []*blockEntry
521- currentText *blockEntry
522- usage Usage
523- stop StopReason = StopEnd
524- finalErr error
525- toolCounter int
539+ blocks []*blockEntry
540+ currentText *blockEntry
541+ currentReasoning *blockEntry
542+ usage Usage
543+ stop StopReason = StopEnd
544+ finalErr error
545+ toolCounter int
526546 )
527547
528- appendText := func(delta string) {
529- if currentText == nil {
530- currentText = &blockEntry{kind: "text"}
531- blocks = append(blocks, currentText)
548+ appendPartText := func(kind, delta, signature string) {
549+ current := &currentText
550+ if kind == "reasoning" {
551+ current = &currentReasoning
552+ }
553+ if *current == nil {
554+ *current = &blockEntry{kind: kind}
555+ blocks = append(blocks, *current)
556+ }
557+ (*current).textBuf.WriteString(delta)
558+ if signature != "" {
559+ (*current).thoughtSignature = signature
560+ // A signature terminates this wire part. A later delta of the
561+ // same kind belongs to a new part and must not be merged into it.
562+ *current = nil
532563 }
533- currentText.textBuf.WriteString(delta)
534564 }
535565
536- appendImage := func(mimeType, dataB64 string) {
566+ appendImage := func(mimeType, dataB64, signature string) {
537567 data, err := base64.StdEncoding.DecodeString(dataB64)
538568 if err != nil || len(data) == 0 {
539569 return
540570 }
541- img := ImageBlock{MimeType: mimeType, Data: data}
571+ img := ImageBlock{MimeType: mimeType, Data: data, ThoughtSignature: signature}
542572 path, _ := saveGeminiImageToWorkingDir(mimeType, data)
543573 blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path})
544- // Image blocks break the current text run.
574+ // Non-text parts break both streamed text runs.
545575 currentText = nil
576+ currentReasoning = nil
546577 }
547578
548579 startTool := func(name string, providedID string, args json.RawMessage) *blockEntry {
@@ -560,8 +591,9 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
560591 t.toolArgs.Write(args)
561592 }
562593 blocks = append(blocks, t)
563- // New tool block breaks the current text run.
594+ // New tool blocks break both streamed text runs.
564595 currentText = nil
596+ currentReasoning = nil
565597 return t
566598 }
567599
@@ -571,7 +603,17 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
571603 switch b.kind {
572604 case "text":
573605 if b.textBuf.Len() > 0 {
574- content = append(content, TextBlock{Text: b.textBuf.String()})
606+ content = append(content, TextBlock{
607+ Text: b.textBuf.String(),
608+ ThoughtSignature: b.thoughtSignature,
609+ })
610+ }
611+ case "reasoning":
612+ if b.textBuf.Len() > 0 || b.thoughtSignature != "" {
613+ content = append(content, ReasoningBlock{
614+ Summary: b.textBuf.String(),
615+ Encrypted: b.thoughtSignature,
616+ })
575617 }
576618 case "image":
577619 if b.image != nil && len(b.image.Data) > 0 {
@@ -586,7 +628,10 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
586628 args = "{}"
587629 }
588630 content = append(content, ToolCallBlock{
589- ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
631+ ID: b.toolID,
632+ Name: b.toolName,
633+ Arguments: json.RawMessage(args),
634+ ThoughtSignature: b.thoughtSignature,
590635 })
591636 }
592637 }
@@ -622,6 +667,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
622667 Text string `json:"text"`
623668 InlineData *gemInlineData `json:"inlineData"`
624669 Thought bool `json:"thought"`
670+ ThoughtSignature string `json:"thoughtSignature"`
625671 FunctionCall *gemFunctionCall `json:"functionCall"`
626672 FunctionCallID string `json:"id"`
627673 FunctionCallName string `json:"name"`
@@ -654,7 +700,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
654700 for _, cand := range chunk.Candidates {
655701 for _, part := range cand.Content.Parts {
656702 if part.InlineData != nil {
657- appendImage(part.InlineData.MimeType, part.InlineData.Data)
703+ appendImage(part.InlineData.MimeType, part.InlineData.Data, part.ThoughtSignature)
658704 continue
659705 }
660706 if part.FunctionCall != nil {
@@ -665,22 +711,22 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
665711 args = json.RawMessage("{}")
666712 }
667713 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
714+ t.thoughtSignature = part.ThoughtSignature
668715 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
669716 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
670717 out <- EventToolEnd{ID: t.toolID}
671718 continue
672719 }
673- if part.Text == "" {
720+ if part.Thought {
721+ appendPartText("reasoning", part.Text, part.ThoughtSignature)
722+ currentText = nil
674723 continue
675724 }
676- 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.
725+ if part.Text == "" {
681726 continue
682727 }
683- appendText(part.Text)
728+ appendPartText("text", part.Text, part.ThoughtSignature)
729+ currentReasoning = nil
684730 out <- EventTextDelta{Delta: part.Text}
685731 }
686732 switch cand.FinishReason {
@@ -80,9 +80,10 @@ type gemPart struct {
80 InlineData *gemInlineData `json:"inlineData,omitempty"`80 InlineData *gemInlineData `json:"inlineData,omitempty"`
81 FunctionCall *gemFunctionCall `json:"functionCall,omitempty"`81 FunctionCall *gemFunctionCall `json:"functionCall,omitempty"`
82 FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"`82 FunctionResponse *gemFunctionResponse `json:"functionResponse,omitempty"`
83- // Thought: true marks a thought-summary part. Outgoing parts83+ // Thought marks a thought-summary part. ThoughtSignature is opaque
84- // from zot never set this; incoming chunks might.84+ // provider state that must be replayed on the same part.
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,11 +289,27 @@ 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+ if v.Encrypted != "" || v.Summary != "" {
294+ parts = append(parts, gemPart{
295+ Text: v.Summary,
296+ Thought: true,
297+ ThoughtSignature: v.Encrypted,
298+ })
299+ }
291 case TextBlock:300 case TextBlock:
292 if strings.TrimSpace(v.Text) == "" {301 if strings.TrimSpace(v.Text) == "" {
293 continue302 continue
294 }303 }
295- parts = append(parts, gemPart{Text: v.Text})304+ parts = append(parts, gemPart{Text: v.Text, ThoughtSignature: v.ThoughtSignature})
305+ case ImageBlock:
306+ parts = append(parts, gemPart{
307+ InlineData: &gemInlineData{
308+ MimeType: v.MimeType,
309+ Data: base64.StdEncoding.EncodeToString(v.Data),
310+ },
311+ ThoughtSignature: v.ThoughtSignature,
312+ })
296 case ToolCallBlock:313 case ToolCallBlock:
297 if !functionsEnabled {314 if !functionsEnabled {
298 continue315 continue
@@ -306,6 +323,7 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
306 Name: v.Name,323 Name: v.Name,
307 Args: args,324 Args: args,
308 },325 },
326+ ThoughtSignature: v.ThoughtSignature,
309 })327 })
310 }328 }
311 }329 }
@@ -508,41 +526,54 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
508 // candidate carries a list of parts (text or functionCall),526 // candidate carries a list of parts (text or functionCall),
509 // possibly accumulating across chunks.527 // possibly accumulating across chunks.
510 type blockEntry struct {528 type blockEntry struct {
511- kind string // "text" | "image" | "tool_use"529+ kind string // "text" | "image" | "tool_use" | "reasoning"
512- textBuf strings.Builder530+ textBuf strings.Builder
513- image *ImageBlock531+ thoughtSignature string
514- imagePath string532+ image *ImageBlock
515- toolID string533+ imagePath string
516- toolName string534+ toolID string
517- toolArgs strings.Builder535+ toolName string
536+ toolArgs strings.Builder
518 }537 }
519 var (538 var (
520- blocks []*blockEntry539+ blocks []*blockEntry
521- currentText *blockEntry540+ currentText *blockEntry
522- usage Usage541+ currentReasoning *blockEntry
523- stop StopReason = StopEnd542+ usage Usage
524- finalErr error543+ stop StopReason = StopEnd
525- toolCounter int544+ finalErr error
545+ toolCounter int
526 )546 )
527 547
528- appendText := func(delta string) {548+ appendPartText := func(kind, delta, signature string) {
529- if currentText == nil {549+ current := &currentText
530- currentText = &blockEntry{kind: "text"}550+ if kind == "reasoning" {
531- blocks = append(blocks, currentText)551+ current = &currentReasoning
552+ }
553+ if *current == nil {
554+ *current = &blockEntry{kind: kind}
555+ blocks = append(blocks, *current)
556+ }
557+ (*current).textBuf.WriteString(delta)
558+ if signature != "" {
559+ (*current).thoughtSignature = signature
560+ // A signature terminates this wire part. A later delta of the
561+ // same kind belongs to a new part and must not be merged into it.
562+ *current = nil
532 }563 }
533- currentText.textBuf.WriteString(delta)
534 }564 }
535 565
536- appendImage := func(mimeType, dataB64 string) {566+ appendImage := func(mimeType, dataB64, signature string) {
537 data, err := base64.StdEncoding.DecodeString(dataB64)567 data, err := base64.StdEncoding.DecodeString(dataB64)
538 if err != nil || len(data) == 0 {568 if err != nil || len(data) == 0 {
539 return569 return
540 }570 }
541- img := ImageBlock{MimeType: mimeType, Data: data}571+ img := ImageBlock{MimeType: mimeType, Data: data, ThoughtSignature: signature}
542 path, _ := saveGeminiImageToWorkingDir(mimeType, data)572 path, _ := saveGeminiImageToWorkingDir(mimeType, data)
543 blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path})573 blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path})
544- // Image blocks break the current text run.574+ // Non-text parts break both streamed text runs.
545 currentText = nil575 currentText = nil
576+ currentReasoning = nil
546 }577 }
547 578
548 startTool := func(name string, providedID string, args json.RawMessage) *blockEntry {579 startTool := func(name string, providedID string, args json.RawMessage) *blockEntry {
@@ -560,8 +591,9 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
560 t.toolArgs.Write(args)591 t.toolArgs.Write(args)
561 }592 }
562 blocks = append(blocks, t)593 blocks = append(blocks, t)
563- // New tool block breaks the current text run.594+ // New tool blocks break both streamed text runs.
564 currentText = nil595 currentText = nil
596+ currentReasoning = nil
565 return t597 return t
566 }598 }
567 599
@@ -571,7 +603,17 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
571 switch b.kind {603 switch b.kind {
572 case "text":604 case "text":
573 if b.textBuf.Len() > 0 {605 if b.textBuf.Len() > 0 {
574- content = append(content, TextBlock{Text: b.textBuf.String()})606+ content = append(content, TextBlock{
607+ Text: b.textBuf.String(),
608+ ThoughtSignature: b.thoughtSignature,
609+ })
610+ }
611+ case "reasoning":
612+ if b.textBuf.Len() > 0 || b.thoughtSignature != "" {
613+ content = append(content, ReasoningBlock{
614+ Summary: b.textBuf.String(),
615+ Encrypted: b.thoughtSignature,
616+ })
575 }617 }
576 case "image":618 case "image":
577 if b.image != nil && len(b.image.Data) > 0 {619 if b.image != nil && len(b.image.Data) > 0 {
@@ -586,7 +628,10 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
586 args = "{}"628 args = "{}"
587 }629 }
588 content = append(content, ToolCallBlock{630 content = append(content, ToolCallBlock{
589- ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),631+ ID: b.toolID,
632+ Name: b.toolName,
633+ Arguments: json.RawMessage(args),
634+ ThoughtSignature: b.thoughtSignature,
590 })635 })
591 }636 }
592 }637 }
@@ -622,6 +667,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
622 Text string `json:"text"`667 Text string `json:"text"`
623 InlineData *gemInlineData `json:"inlineData"`668 InlineData *gemInlineData `json:"inlineData"`
624 Thought bool `json:"thought"`669 Thought bool `json:"thought"`
670+ ThoughtSignature string `json:"thoughtSignature"`
625 FunctionCall *gemFunctionCall `json:"functionCall"`671 FunctionCall *gemFunctionCall `json:"functionCall"`
626 FunctionCallID string `json:"id"`672 FunctionCallID string `json:"id"`
627 FunctionCallName string `json:"name"`673 FunctionCallName string `json:"name"`
@@ -654,7 +700,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
654 for _, cand := range chunk.Candidates {700 for _, cand := range chunk.Candidates {
655 for _, part := range cand.Content.Parts {701 for _, part := range cand.Content.Parts {
656 if part.InlineData != nil {702 if part.InlineData != nil {
657- appendImage(part.InlineData.MimeType, part.InlineData.Data)703+ appendImage(part.InlineData.MimeType, part.InlineData.Data, part.ThoughtSignature)
658 continue704 continue
659 }705 }
660 if part.FunctionCall != nil {706 if part.FunctionCall != nil {
@@ -665,22 +711,22 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
665 args = json.RawMessage("{}")711 args = json.RawMessage("{}")
666 }712 }
667 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)713 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
714+ t.thoughtSignature = part.ThoughtSignature
668 out <- EventToolStart{ID: t.toolID, Name: t.toolName}715 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
669 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}716 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
670 out <- EventToolEnd{ID: t.toolID}717 out <- EventToolEnd{ID: t.toolID}
671 continue718 continue
672 }719 }
673- if part.Text == "" {720+ if part.Thought {
721+ appendPartText("reasoning", part.Text, part.ThoughtSignature)
722+ currentText = nil
674 continue723 continue
675 }724 }
676- if part.Thought {725+ if part.Text == "" {
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.
681 continue726 continue
682 }727 }
683- appendText(part.Text)728+ appendPartText("text", part.Text, part.ThoughtSignature)
729+ currentReasoning = nil
684 out <- EventTextDelta{Delta: part.Text}730 out <- EventTextDelta{Delta: part.Text}
685 }731 }
686 switch cand.FinishReason {732 switch cand.FinishReason {
M packages/provider/gemini_test.go +255 -0
@@ -329,6 +329,75 @@ func TestGeminiThinkingConfig(t *testing.T) {
329329 }
330330 }
331331
332+func TestGeminiThoughtSignaturesStreamAndReplay(t *testing.T) {
333+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
334+ w.Header().Set("content-type", "text/event-stream")
335+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"think","thought":true}]}}]}`+"\n\n")
336+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"ing","thought":true,"thoughtSignature":"reasoning-sig"}]}}]}`+"\n\n")
337+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"ans"}]}}]}`+"\n\n")
338+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"wer","thoughtSignature":"text-sig"}]}}]}`+"\n\n")
339+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig"}]},"finishReason":"STOP"}]}`+"\n\n")
340+ }))
341+ defer srv.Close()
342+
343+ client := NewGemini("k", srv.URL).(*geminiClient)
344+ events, err := client.Stream(context.Background(), Request{Model: "gemini-3-flash"})
345+ if err != nil {
346+ t.Fatal(err)
347+ }
348+ var done EventDone
349+ for event := range events {
350+ if event, ok := event.(EventDone); ok {
351+ done = event
352+ }
353+ }
354+ if len(done.Message.Content) != 3 {
355+ t.Fatalf("content = %#v", done.Message.Content)
356+ }
357+ reasoning, ok := done.Message.Content[0].(ReasoningBlock)
358+ if !ok || reasoning.Summary != "thinking" || reasoning.Encrypted != "reasoning-sig" {
359+ t.Fatalf("reasoning = %#v", done.Message.Content[0])
360+ }
361+ text, ok := done.Message.Content[1].(TextBlock)
362+ if !ok || text.Text != "answer" || text.ThoughtSignature != "text-sig" {
363+ t.Fatalf("text = %#v", done.Message.Content[1])
364+ }
365+ tool, ok := done.Message.Content[2].(ToolCallBlock)
366+ if !ok || tool.ThoughtSignature != "tool-sig" {
367+ t.Fatalf("tool = %#v", done.Message.Content[2])
368+ }
369+
370+ wire, _, err := client.buildRequest(Request{
371+ Model: "gemini-3-flash",
372+ Messages: []Message{done.Message},
373+ })
374+ if err != nil {
375+ t.Fatal(err)
376+ }
377+ parts := wire.Contents[0].Parts
378+ if len(parts) != 3 || parts[0].ThoughtSignature != "reasoning-sig" ||
379+ parts[1].ThoughtSignature != "text-sig" || parts[2].ThoughtSignature != "tool-sig" {
380+ t.Fatalf("replayed parts = %#v", parts)
381+ }
382+}
383+
384+func TestGeminiAssistantImageReplaysThoughtSignature(t *testing.T) {
385+ client := NewGemini("k", "https://example.invalid").(*geminiClient)
386+ wire, _, err := client.buildRequest(Request{
387+ Model: "gemini-3-flash",
388+ Messages: []Message{{Role: RoleAssistant, Content: []Content{
389+ ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"},
390+ }}},
391+ })
392+ if err != nil {
393+ t.Fatal(err)
394+ }
395+ part := wire.Contents[0].Parts[0]
396+ if part.InlineData == nil || part.ThoughtSignature != "image-sig" {
397+ t.Fatalf("image part = %#v", part)
398+ }
399+}
400+
332401 // TestDiscoverGoogle exercises the discovery helper against a fake
333402 // /v1beta/models endpoint, confirming pagination plus filtering of
334403 // non-chat ids (embedding, aqa).
@@ -375,3 +444,189 @@ func TestDiscoverGoogle(t *testing.T) {
375444 t.Errorf("second model wrong: %+v", got[1])
376445 }
377446 }
447+
448+// TestSanitizeGeminiToolSchemaEmptyOrInvalid covers the pre-existing
449+// fallback behavior for empty/invalid input, to make sure the new logic
450+// didn't regress it.
451+func TestSanitizeGeminiToolSchemaEmptyOrInvalid(t *testing.T) {
452+ for _, in := range []json.RawMessage{nil, json.RawMessage(``), json.RawMessage(`not json`)} {
453+ out := sanitizeGeminiToolSchema(in)
454+ var got map[string]any
455+ if err := json.Unmarshal(out, &got); err != nil {
456+ t.Fatalf("fallback schema is not valid JSON: %v (%s)", err, out)
457+ }
458+ if got["type"] != "object" {
459+ t.Fatalf("want type=object fallback, got %+v", got)
460+ }
461+ if _, ok := got["properties"].(map[string]any); !ok {
462+ t.Fatalf("want properties object fallback, got %+v", got)
463+ }
464+ }
465+}
466+
467+// TestGeminiStreamReasoning confirms that when the Gemini SSE stream
468+// contains thought blocks with a signature, they are correctly mapped to
469+// ReasoningBlock structures inside the final assistant message.
470+func TestGeminiStreamReasoning(t *testing.T) {
471+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
472+ w.Header().Set("content-type", "text/event-stream")
473+ w.WriteHeader(http.StatusOK)
474+ fl, _ := w.(http.Flusher)
475+ write := func(s string) {
476+ _, _ = w.Write([]byte(s))
477+ if fl != nil {
478+ fl.Flush()
479+ }
480+ }
481+ // Write a thought part with signature, then text, then usage containing thoughtsTokenCount.
482+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Thinking hard","thought":true,"thoughtSignature":"sig123"}]}}]}` + "\n\n")
483+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Answer text"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":10}}` + "\n\n")
484+ }))
485+ defer srv.Close()
486+
487+ c := NewGemini("k", srv.URL)
488+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
489+ if err != nil {
490+ t.Fatal(err)
491+ }
492+
493+ var gotText string
494+ var done EventDone
495+ var usage Usage
496+ for ev := range evs {
497+ switch e := ev.(type) {
498+ case EventTextDelta:
499+ gotText += e.Delta
500+ case EventUsage:
501+ usage = e.Usage
502+ case EventDone:
503+ done = e
504+ }
505+ }
506+
507+ if gotText != "Answer text" {
508+ t.Fatalf("text=%q", gotText)
509+ }
510+ if usage.OutputTokens != 5 { // candidatesTokenCount (2) + thoughtsTokenCount (3)
511+ t.Fatalf("usage output tokens=%d, want 5", usage.OutputTokens)
512+ }
513+
514+ // Verify that the final message contains both the ReasoningBlock and the TextBlock
515+ msg := done.Message
516+ if len(msg.Content) != 2 {
517+ t.Fatalf("want 2 content blocks, got %d: %+v", len(msg.Content), msg.Content)
518+ }
519+
520+ rb, ok := msg.Content[0].(ReasoningBlock)
521+ if !ok {
522+ t.Fatalf("first block should be ReasoningBlock, got %T", msg.Content[0])
523+ }
524+ if rb.Summary != "Thinking hard" || rb.Encrypted != "sig123" {
525+ t.Fatalf("invalid ReasoningBlock: %+v", rb)
526+ }
527+
528+ tb, ok := msg.Content[1].(TextBlock)
529+ if !ok {
530+ t.Fatalf("second block should be TextBlock, got %T", msg.Content[1])
531+ }
532+ if tb.Text != "Answer text" {
533+ t.Fatalf("invalid TextBlock: %+v", tb)
534+ }
535+}
536+
537+// TestGeminiBuildRequestWithReasoningReplay verifies that when zot sends a
538+// historical assistant message containing a ReasoningBlock, buildRequest
539+// serializes it into the verbatim Gemini "thought: true" wire representation
540+// with its corresponding thoughtSignature.
541+func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) {
542+ c := NewGemini("k", "https://example.invalid").(*geminiClient)
543+ wire, _, err := c.buildRequest(Request{
544+ Model: "gemini-2.5-pro",
545+ Messages: []Message{
546+ {Role: RoleUser, Content: []Content{TextBlock{Text: "Why is the sky blue?"}}},
547+ {Role: RoleAssistant, Content: []Content{
548+ ReasoningBlock{Summary: "Evaluating Rayleigh scattering", Encrypted: "sky-sig-999"},
549+ TextBlock{Text: "It scatters blue light more than other colors."},
550+ }},
551+ },
552+ })
553+ if err != nil {
554+ t.Fatal(err)
555+ }
556+
557+ if len(wire.Contents) != 2 {
558+ t.Fatalf("want 2 messages in wire, got %d", len(wire.Contents))
559+ }
560+
561+ assistant := wire.Contents[1]
562+ if assistant.Role != "model" {
563+ t.Fatalf("assistant role should be 'model', got %q", assistant.Role)
564+ }
565+
566+ if len(assistant.Parts) != 2 {
567+ t.Fatalf("want 2 parts, got %d: %+v", len(assistant.Parts), assistant.Parts)
568+ }
569+
570+ // Check the serialized ReasoningBlock
571+ p0 := assistant.Parts[0]
572+ if !p0.Thought {
573+ t.Errorf("part 0: expected Thought=true, got %+v", p0)
574+ }
575+ if p0.Text != "Evaluating Rayleigh scattering" {
576+ t.Errorf("part 0: expected text to match summary, got %q", p0.Text)
577+ }
578+ if p0.ThoughtSignature != "sky-sig-999" {
579+ t.Errorf("part 0: expected thought signature 'sky-sig-999', got %q", p0.ThoughtSignature)
580+ }
581+
582+ // Check the serialized TextBlock
583+ p1 := assistant.Parts[1]
584+ if p1.Thought {
585+ t.Errorf("part 1: expected Thought=false, got %+v", p1)
586+ }
587+ if p1.Text != "It scatters blue light more than other colors." {
588+ t.Errorf("part 1: expected text to match, got %q", p1.Text)
589+ }
590+}
591+
592+// TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call
593+// featuring a thoughtSignature in the SSE stream successfully extracts and
594+// attaches the thought signature onto the final ToolCallBlock.
595+func TestGeminiStreamToolCallWithThoughtSignature(t *testing.T) {
596+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
597+ w.Header().Set("content-type", "text/event-stream")
598+ w.WriteHeader(http.StatusOK)
599+ _, _ = w.Write([]byte("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig-456"}]},"finishReason":"STOP"}]}` + "\n\n"))
600+ }))
601+ defer srv.Close()
602+
603+ c := NewGemini("k", srv.URL)
604+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
605+ if err != nil {
606+ t.Fatal(err)
607+ }
608+
609+ var done EventDone
610+ for ev := range evs {
611+ if e, ok := ev.(EventDone); ok {
612+ done = e
613+ }
614+ }
615+
616+ if len(done.Message.Content) != 1 {
617+ t.Fatalf("want 1 content block, got %d", len(done.Message.Content))
618+ }
619+
620+ tc, ok := done.Message.Content[0].(ToolCallBlock)
621+ if !ok {
622+ t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0])
623+ }
624+
625+ if tc.Name != "read" {
626+ t.Fatalf("tool name=%q, want 'read'", tc.Name)
627+ }
628+
629+ if tc.ThoughtSignature != "tool-sig-456" {
630+ t.Fatalf("tool block thought signature=%q, want 'tool-sig-456'", tc.ThoughtSignature)
631+ }
632+}
@@ -329,6 +329,75 @@ func TestGeminiThinkingConfig(t *testing.T) {
329 }329 }
330 }330 }
331 331
332+func TestGeminiThoughtSignaturesStreamAndReplay(t *testing.T) {
333+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
334+ w.Header().Set("content-type", "text/event-stream")
335+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"think","thought":true}]}}]}`+"\n\n")
336+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"ing","thought":true,"thoughtSignature":"reasoning-sig"}]}}]}`+"\n\n")
337+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"ans"}]}}]}`+"\n\n")
338+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"text":"wer","thoughtSignature":"text-sig"}]}}]}`+"\n\n")
339+ _, _ = io.WriteString(w, "data: "+`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig"}]},"finishReason":"STOP"}]}`+"\n\n")
340+ }))
341+ defer srv.Close()
342+
343+ client := NewGemini("k", srv.URL).(*geminiClient)
344+ events, err := client.Stream(context.Background(), Request{Model: "gemini-3-flash"})
345+ if err != nil {
346+ t.Fatal(err)
347+ }
348+ var done EventDone
349+ for event := range events {
350+ if event, ok := event.(EventDone); ok {
351+ done = event
352+ }
353+ }
354+ if len(done.Message.Content) != 3 {
355+ t.Fatalf("content = %#v", done.Message.Content)
356+ }
357+ reasoning, ok := done.Message.Content[0].(ReasoningBlock)
358+ if !ok || reasoning.Summary != "thinking" || reasoning.Encrypted != "reasoning-sig" {
359+ t.Fatalf("reasoning = %#v", done.Message.Content[0])
360+ }
361+ text, ok := done.Message.Content[1].(TextBlock)
362+ if !ok || text.Text != "answer" || text.ThoughtSignature != "text-sig" {
363+ t.Fatalf("text = %#v", done.Message.Content[1])
364+ }
365+ tool, ok := done.Message.Content[2].(ToolCallBlock)
366+ if !ok || tool.ThoughtSignature != "tool-sig" {
367+ t.Fatalf("tool = %#v", done.Message.Content[2])
368+ }
369+
370+ wire, _, err := client.buildRequest(Request{
371+ Model: "gemini-3-flash",
372+ Messages: []Message{done.Message},
373+ })
374+ if err != nil {
375+ t.Fatal(err)
376+ }
377+ parts := wire.Contents[0].Parts
378+ if len(parts) != 3 || parts[0].ThoughtSignature != "reasoning-sig" ||
379+ parts[1].ThoughtSignature != "text-sig" || parts[2].ThoughtSignature != "tool-sig" {
380+ t.Fatalf("replayed parts = %#v", parts)
381+ }
382+}
383+
384+func TestGeminiAssistantImageReplaysThoughtSignature(t *testing.T) {
385+ client := NewGemini("k", "https://example.invalid").(*geminiClient)
386+ wire, _, err := client.buildRequest(Request{
387+ Model: "gemini-3-flash",
388+ Messages: []Message{{Role: RoleAssistant, Content: []Content{
389+ ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "image-sig"},
390+ }}},
391+ })
392+ if err != nil {
393+ t.Fatal(err)
394+ }
395+ part := wire.Contents[0].Parts[0]
396+ if part.InlineData == nil || part.ThoughtSignature != "image-sig" {
397+ t.Fatalf("image part = %#v", part)
398+ }
399+}
400+
332 // TestDiscoverGoogle exercises the discovery helper against a fake401 // TestDiscoverGoogle exercises the discovery helper against a fake
333 // /v1beta/models endpoint, confirming pagination plus filtering of402 // /v1beta/models endpoint, confirming pagination plus filtering of
334 // non-chat ids (embedding, aqa).403 // non-chat ids (embedding, aqa).
@@ -375,3 +444,189 @@ func TestDiscoverGoogle(t *testing.T) {
375 t.Errorf("second model wrong: %+v", got[1])444 t.Errorf("second model wrong: %+v", got[1])
376 }445 }
377 }446 }
447+
448+// TestSanitizeGeminiToolSchemaEmptyOrInvalid covers the pre-existing
449+// fallback behavior for empty/invalid input, to make sure the new logic
450+// didn't regress it.
451+func TestSanitizeGeminiToolSchemaEmptyOrInvalid(t *testing.T) {
452+ for _, in := range []json.RawMessage{nil, json.RawMessage(``), json.RawMessage(`not json`)} {
453+ out := sanitizeGeminiToolSchema(in)
454+ var got map[string]any
455+ if err := json.Unmarshal(out, &got); err != nil {
456+ t.Fatalf("fallback schema is not valid JSON: %v (%s)", err, out)
457+ }
458+ if got["type"] != "object" {
459+ t.Fatalf("want type=object fallback, got %+v", got)
460+ }
461+ if _, ok := got["properties"].(map[string]any); !ok {
462+ t.Fatalf("want properties object fallback, got %+v", got)
463+ }
464+ }
465+}
466+
467+// TestGeminiStreamReasoning confirms that when the Gemini SSE stream
468+// contains thought blocks with a signature, they are correctly mapped to
469+// ReasoningBlock structures inside the final assistant message.
470+func TestGeminiStreamReasoning(t *testing.T) {
471+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
472+ w.Header().Set("content-type", "text/event-stream")
473+ w.WriteHeader(http.StatusOK)
474+ fl, _ := w.(http.Flusher)
475+ write := func(s string) {
476+ _, _ = w.Write([]byte(s))
477+ if fl != nil {
478+ fl.Flush()
479+ }
480+ }
481+ // Write a thought part with signature, then text, then usage containing thoughtsTokenCount.
482+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Thinking hard","thought":true,"thoughtSignature":"sig123"}]}}]}` + "\n\n")
483+ write("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"text":"Answer text"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":10}}` + "\n\n")
484+ }))
485+ defer srv.Close()
486+
487+ c := NewGemini("k", srv.URL)
488+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
489+ if err != nil {
490+ t.Fatal(err)
491+ }
492+
493+ var gotText string
494+ var done EventDone
495+ var usage Usage
496+ for ev := range evs {
497+ switch e := ev.(type) {
498+ case EventTextDelta:
499+ gotText += e.Delta
500+ case EventUsage:
501+ usage = e.Usage
502+ case EventDone:
503+ done = e
504+ }
505+ }
506+
507+ if gotText != "Answer text" {
508+ t.Fatalf("text=%q", gotText)
509+ }
510+ if usage.OutputTokens != 5 { // candidatesTokenCount (2) + thoughtsTokenCount (3)
511+ t.Fatalf("usage output tokens=%d, want 5", usage.OutputTokens)
512+ }
513+
514+ // Verify that the final message contains both the ReasoningBlock and the TextBlock
515+ msg := done.Message
516+ if len(msg.Content) != 2 {
517+ t.Fatalf("want 2 content blocks, got %d: %+v", len(msg.Content), msg.Content)
518+ }
519+
520+ rb, ok := msg.Content[0].(ReasoningBlock)
521+ if !ok {
522+ t.Fatalf("first block should be ReasoningBlock, got %T", msg.Content[0])
523+ }
524+ if rb.Summary != "Thinking hard" || rb.Encrypted != "sig123" {
525+ t.Fatalf("invalid ReasoningBlock: %+v", rb)
526+ }
527+
528+ tb, ok := msg.Content[1].(TextBlock)
529+ if !ok {
530+ t.Fatalf("second block should be TextBlock, got %T", msg.Content[1])
531+ }
532+ if tb.Text != "Answer text" {
533+ t.Fatalf("invalid TextBlock: %+v", tb)
534+ }
535+}
536+
537+// TestGeminiBuildRequestWithReasoningReplay verifies that when zot sends a
538+// historical assistant message containing a ReasoningBlock, buildRequest
539+// serializes it into the verbatim Gemini "thought: true" wire representation
540+// with its corresponding thoughtSignature.
541+func TestGeminiBuildRequestWithReasoningReplay(t *testing.T) {
542+ c := NewGemini("k", "https://example.invalid").(*geminiClient)
543+ wire, _, err := c.buildRequest(Request{
544+ Model: "gemini-2.5-pro",
545+ Messages: []Message{
546+ {Role: RoleUser, Content: []Content{TextBlock{Text: "Why is the sky blue?"}}},
547+ {Role: RoleAssistant, Content: []Content{
548+ ReasoningBlock{Summary: "Evaluating Rayleigh scattering", Encrypted: "sky-sig-999"},
549+ TextBlock{Text: "It scatters blue light more than other colors."},
550+ }},
551+ },
552+ })
553+ if err != nil {
554+ t.Fatal(err)
555+ }
556+
557+ if len(wire.Contents) != 2 {
558+ t.Fatalf("want 2 messages in wire, got %d", len(wire.Contents))
559+ }
560+
561+ assistant := wire.Contents[1]
562+ if assistant.Role != "model" {
563+ t.Fatalf("assistant role should be 'model', got %q", assistant.Role)
564+ }
565+
566+ if len(assistant.Parts) != 2 {
567+ t.Fatalf("want 2 parts, got %d: %+v", len(assistant.Parts), assistant.Parts)
568+ }
569+
570+ // Check the serialized ReasoningBlock
571+ p0 := assistant.Parts[0]
572+ if !p0.Thought {
573+ t.Errorf("part 0: expected Thought=true, got %+v", p0)
574+ }
575+ if p0.Text != "Evaluating Rayleigh scattering" {
576+ t.Errorf("part 0: expected text to match summary, got %q", p0.Text)
577+ }
578+ if p0.ThoughtSignature != "sky-sig-999" {
579+ t.Errorf("part 0: expected thought signature 'sky-sig-999', got %q", p0.ThoughtSignature)
580+ }
581+
582+ // Check the serialized TextBlock
583+ p1 := assistant.Parts[1]
584+ if p1.Thought {
585+ t.Errorf("part 1: expected Thought=false, got %+v", p1)
586+ }
587+ if p1.Text != "It scatters blue light more than other colors." {
588+ t.Errorf("part 1: expected text to match, got %q", p1.Text)
589+ }
590+}
591+
592+// TestGeminiStreamToolCallWithThoughtSignature confirms that a tool call
593+// featuring a thoughtSignature in the SSE stream successfully extracts and
594+// attaches the thought signature onto the final ToolCallBlock.
595+func TestGeminiStreamToolCallWithThoughtSignature(t *testing.T) {
596+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
597+ w.Header().Set("content-type", "text/event-stream")
598+ w.WriteHeader(http.StatusOK)
599+ _, _ = w.Write([]byte("data: " + `{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"read","args":{"path":"a"}},"thoughtSignature":"tool-sig-456"}]},"finishReason":"STOP"}]}` + "\n\n"))
600+ }))
601+ defer srv.Close()
602+
603+ c := NewGemini("k", srv.URL)
604+ evs, err := c.Stream(context.Background(), Request{Model: "gemini-2.5-pro"})
605+ if err != nil {
606+ t.Fatal(err)
607+ }
608+
609+ var done EventDone
610+ for ev := range evs {
611+ if e, ok := ev.(EventDone); ok {
612+ done = e
613+ }
614+ }
615+
616+ if len(done.Message.Content) != 1 {
617+ t.Fatalf("want 1 content block, got %d", len(done.Message.Content))
618+ }
619+
620+ tc, ok := done.Message.Content[0].(ToolCallBlock)
621+ if !ok {
622+ t.Fatalf("expected ToolCallBlock, got %T", done.Message.Content[0])
623+ }
624+
625+ if tc.Name != "read" {
626+ t.Fatalf("tool name=%q, want 'read'", tc.Name)
627+ }
628+
629+ if tc.ThoughtSignature != "tool-sig-456" {
630+ t.Fatalf("tool block thought signature=%q, want 'tool-sig-456'", tc.ThoughtSignature)
631+ }
632+}
M packages/provider/provider.go +9 -6
@@ -28,24 +28,27 @@ type Content interface {
2828
2929 // TextBlock is plain text content.
3030 type TextBlock struct {
31- Text string `json:"text"`
31+ Text string `json:"text"`
32+ ThoughtSignature string `json:"thought_signature,omitempty"`
3233 }
3334
3435 func (TextBlock) isContent() {}
3536
3637 // ImageBlock is an inline image (PNG/JPEG/GIF/WebP).
3738 type ImageBlock struct {
38- MimeType string `json:"mime_type"`
39- Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire
39+ MimeType string `json:"mime_type"`
40+ Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire
41+ ThoughtSignature string `json:"thought_signature,omitempty"`
4042 }
4143
4244 func (ImageBlock) isContent() {}
4345
4446 // ToolCallBlock is an assistant-issued call to a tool.
4547 type ToolCallBlock struct {
46- ID string `json:"id"`
47- Name string `json:"name"`
48- Arguments json.RawMessage `json:"arguments"`
48+ ID string `json:"id"`
49+ Name string `json:"name"`
50+ Arguments json.RawMessage `json:"arguments"`
51+ ThoughtSignature string `json:"thought_signature,omitempty"`
4952 }
5053
5154 func (ToolCallBlock) isContent() {}
@@ -28,24 +28,27 @@ type Content interface {
28 28
29 // TextBlock is plain text content.29 // TextBlock is plain text content.
30 type TextBlock struct {30 type TextBlock struct {
31- Text string `json:"text"`31+ Text string `json:"text"`
32+ ThoughtSignature string `json:"thought_signature,omitempty"`
32 }33 }
33 34
34 func (TextBlock) isContent() {}35 func (TextBlock) isContent() {}
35 36
36 // ImageBlock is an inline image (PNG/JPEG/GIF/WebP).37 // ImageBlock is an inline image (PNG/JPEG/GIF/WebP).
37 type ImageBlock struct {38 type ImageBlock struct {
38- MimeType string `json:"mime_type"`39+ MimeType string `json:"mime_type"`
39- Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire40+ Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire
41+ ThoughtSignature string `json:"thought_signature,omitempty"`
40 }42 }
41 43
42 func (ImageBlock) isContent() {}44 func (ImageBlock) isContent() {}
43 45
44 // ToolCallBlock is an assistant-issued call to a tool.46 // ToolCallBlock is an assistant-issued call to a tool.
45 type ToolCallBlock struct {47 type ToolCallBlock struct {
46- ID string `json:"id"`48+ ID string `json:"id"`
47- Name string `json:"name"`49+ Name string `json:"name"`
48- Arguments json.RawMessage `json:"arguments"`50+ Arguments json.RawMessage `json:"arguments"`
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