detached d757bae

Fix Gemini thought signature replay

commit
d757bae3b327c7437c8a0727d54334bbc4073616
author
patriceckhart · · 2026-07-16 09:14 UTC
parent
056d214
signature
unsigned
5 files · +232 -73
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 +70 -52
@@ -80,8 +80,8 @@ 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.
83+ // Thought marks a thought-summary part. ThoughtSignature is opaque
84+ // provider state that must be replayed on the same part.
8585 Thought bool `json:"thought,omitempty"`
8686 ThoughtSignature string `json:"thoughtSignature,omitempty"`
8787 }
@@ -290,8 +290,6 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
290290 for _, b := range blocks {
291291 switch v := b.(type) {
292292 case ReasoningBlock:
293- // Vertex AI requires thought parts with their signature to be
294- // replayed verbatim on follow-up turns.
295293 if v.Encrypted != "" || v.Summary != "" {
296294 parts = append(parts, gemPart{
297295 Text: v.Summary,
@@ -303,7 +301,15 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
303301 if strings.TrimSpace(v.Text) == "" {
304302 continue
305303 }
306- 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+ })
307313 case ToolCallBlock:
308314 if !functionsEnabled {
309315 continue
@@ -520,44 +526,54 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
520526 // candidate carries a list of parts (text or functionCall),
521527 // possibly accumulating across chunks.
522528 type blockEntry struct {
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
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
533537 }
534538 var (
535- blocks []*blockEntry
536- currentText *blockEntry
537- usage Usage
538- stop StopReason = StopEnd
539- finalErr error
540- toolCounter int
539+ blocks []*blockEntry
540+ currentText *blockEntry
541+ currentReasoning *blockEntry
542+ usage Usage
543+ stop StopReason = StopEnd
544+ finalErr error
545+ toolCounter int
541546 )
542547
543- appendText := func(delta string) {
544- if currentText == nil {
545- currentText = &blockEntry{kind: "text"}
546- 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
547563 }
548- currentText.textBuf.WriteString(delta)
549564 }
550565
551- appendImage := func(mimeType, dataB64 string) {
566+ appendImage := func(mimeType, dataB64, signature string) {
552567 data, err := base64.StdEncoding.DecodeString(dataB64)
553568 if err != nil || len(data) == 0 {
554569 return
555570 }
556- img := ImageBlock{MimeType: mimeType, Data: data}
571+ img := ImageBlock{MimeType: mimeType, Data: data, ThoughtSignature: signature}
557572 path, _ := saveGeminiImageToWorkingDir(mimeType, data)
558573 blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path})
559- // Image blocks break the current text run.
574+ // Non-text parts break both streamed text runs.
560575 currentText = nil
576+ currentReasoning = nil
561577 }
562578
563579 startTool := func(name string, providedID string, args json.RawMessage) *blockEntry {
@@ -575,8 +591,9 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
575591 t.toolArgs.Write(args)
576592 }
577593 blocks = append(blocks, t)
578- // New tool block breaks the current text run.
594+ // New tool blocks break both streamed text runs.
579595 currentText = nil
596+ currentReasoning = nil
580597 return t
581598 }
582599
@@ -586,7 +603,17 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
586603 switch b.kind {
587604 case "text":
588605 if b.textBuf.Len() > 0 {
589- 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+ })
590617 }
591618 case "image":
592619 if b.image != nil && len(b.image.Data) > 0 {
@@ -595,19 +622,16 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
595622 content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)})
596623 }
597624 }
598- case "reasoning":
599- content = append(content, ReasoningBlock{
600- Summary: b.reasoningSummary,
601- Encrypted: b.reasoningSignature,
602- })
603625 case "tool_use":
604626 args := b.toolArgs.String()
605627 if args == "" || !json.Valid([]byte(args)) {
606628 args = "{}"
607629 }
608630 content = append(content, ToolCallBlock{
609- ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
610- ThoughtSignature: b.toolThoughtSig,
631+ ID: b.toolID,
632+ Name: b.toolName,
633+ Arguments: json.RawMessage(args),
634+ ThoughtSignature: b.thoughtSignature,
611635 })
612636 }
613637 }
@@ -676,7 +700,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
676700 for _, cand := range chunk.Candidates {
677701 for _, part := range cand.Content.Parts {
678702 if part.InlineData != nil {
679- appendImage(part.InlineData.MimeType, part.InlineData.Data)
703+ appendImage(part.InlineData.MimeType, part.InlineData.Data, part.ThoughtSignature)
680704 continue
681705 }
682706 if part.FunctionCall != nil {
@@ -687,28 +711,22 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
687711 args = json.RawMessage("{}")
688712 }
689713 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
690- t.toolThoughtSig = part.ThoughtSignature
714+ t.thoughtSignature = part.ThoughtSignature
691715 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
692716 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
693717 out <- EventToolEnd{ID: t.toolID}
694718 continue
695719 }
696- if part.Text == "" {
697- continue
698- }
699720 if part.Thought {
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- })
721+ appendPartText("reasoning", part.Text, part.ThoughtSignature)
708722 currentText = nil
709723 continue
710724 }
711- appendText(part.Text)
725+ if part.Text == "" {
726+ continue
727+ }
728+ appendPartText("text", part.Text, part.ThoughtSignature)
729+ currentReasoning = nil
712730 out <- EventTextDelta{Delta: part.Text}
713731 }
714732 switch cand.FinishReason {
@@ -80,8 +80,8 @@ 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 ThoughtSignature string `json:"thoughtSignature,omitempty"`
87 }87 }
@@ -290,8 +290,6 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
290 for _, b := range blocks {290 for _, b := range blocks {
291 switch v := b.(type) {291 switch v := b.(type) {
292 case ReasoningBlock: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 != "" {293 if v.Encrypted != "" || v.Summary != "" {
296 parts = append(parts, gemPart{294 parts = append(parts, gemPart{
297 Text: v.Summary,295 Text: v.Summary,
@@ -303,7 +301,15 @@ func convertGemAssistantParts(blocks []Content, functionsEnabled bool) []gemPart
303 if strings.TrimSpace(v.Text) == "" {301 if strings.TrimSpace(v.Text) == "" {
304 continue302 continue
305 }303 }
306- 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+ })
307 case ToolCallBlock:313 case ToolCallBlock:
308 if !functionsEnabled {314 if !functionsEnabled {
309 continue315 continue
@@ -520,44 +526,54 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
520 // candidate carries a list of parts (text or functionCall),526 // candidate carries a list of parts (text or functionCall),
521 // possibly accumulating across chunks.527 // possibly accumulating across chunks.
522 type blockEntry struct {528 type blockEntry struct {
523- kind string // "text" | "image" | "tool_use" | "reasoning"529+ kind string // "text" | "image" | "tool_use" | "reasoning"
524- textBuf strings.Builder530+ textBuf strings.Builder
525- image *ImageBlock531+ thoughtSignature string
526- imagePath string532+ image *ImageBlock
527- toolID string533+ imagePath string
528- toolName string534+ toolID string
529- toolArgs strings.Builder535+ toolName string
530- toolThoughtSig string536+ toolArgs strings.Builder
531- reasoningSummary string
532- reasoningSignature string
533 }537 }
534 var (538 var (
535- blocks []*blockEntry539+ blocks []*blockEntry
536- currentText *blockEntry540+ currentText *blockEntry
537- usage Usage541+ currentReasoning *blockEntry
538- stop StopReason = StopEnd542+ usage Usage
539- finalErr error543+ stop StopReason = StopEnd
540- toolCounter int544+ finalErr error
545+ toolCounter int
541 )546 )
542 547
543- appendText := func(delta string) {548+ appendPartText := func(kind, delta, signature string) {
544- if currentText == nil {549+ current := &currentText
545- currentText = &blockEntry{kind: "text"}550+ if kind == "reasoning" {
546- 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
547 }563 }
548- currentText.textBuf.WriteString(delta)
549 }564 }
550 565
551- appendImage := func(mimeType, dataB64 string) {566+ appendImage := func(mimeType, dataB64, signature string) {
552 data, err := base64.StdEncoding.DecodeString(dataB64)567 data, err := base64.StdEncoding.DecodeString(dataB64)
553 if err != nil || len(data) == 0 {568 if err != nil || len(data) == 0 {
554 return569 return
555 }570 }
556- img := ImageBlock{MimeType: mimeType, Data: data}571+ img := ImageBlock{MimeType: mimeType, Data: data, ThoughtSignature: signature}
557 path, _ := saveGeminiImageToWorkingDir(mimeType, data)572 path, _ := saveGeminiImageToWorkingDir(mimeType, data)
558 blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path})573 blocks = append(blocks, &blockEntry{kind: "image", image: &img, imagePath: path})
559- // Image blocks break the current text run.574+ // Non-text parts break both streamed text runs.
560 currentText = nil575 currentText = nil
576+ currentReasoning = nil
561 }577 }
562 578
563 startTool := func(name string, providedID string, args json.RawMessage) *blockEntry {579 startTool := func(name string, providedID string, args json.RawMessage) *blockEntry {
@@ -575,8 +591,9 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
575 t.toolArgs.Write(args)591 t.toolArgs.Write(args)
576 }592 }
577 blocks = append(blocks, t)593 blocks = append(blocks, t)
578- // New tool block breaks the current text run.594+ // New tool blocks break both streamed text runs.
579 currentText = nil595 currentText = nil
596+ currentReasoning = nil
580 return t597 return t
581 }598 }
582 599
@@ -586,7 +603,17 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
586 switch b.kind {603 switch b.kind {
587 case "text":604 case "text":
588 if b.textBuf.Len() > 0 {605 if b.textBuf.Len() > 0 {
589- 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+ })
590 }617 }
591 case "image":618 case "image":
592 if b.image != nil && len(b.image.Data) > 0 {619 if b.image != nil && len(b.image.Data) > 0 {
@@ -595,19 +622,16 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
595 content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)})622 content = append(content, TextBlock{Text: fmt.Sprintf("Saved image: `%s`", b.imagePath)})
596 }623 }
597 }624 }
598- case "reasoning":
599- content = append(content, ReasoningBlock{
600- Summary: b.reasoningSummary,
601- Encrypted: b.reasoningSignature,
602- })
603 case "tool_use":625 case "tool_use":
604 args := b.toolArgs.String()626 args := b.toolArgs.String()
605 if args == "" || !json.Valid([]byte(args)) {627 if args == "" || !json.Valid([]byte(args)) {
606 args = "{}"628 args = "{}"
607 }629 }
608 content = append(content, ToolCallBlock{630 content = append(content, ToolCallBlock{
609- ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),631+ ID: b.toolID,
610- ThoughtSignature: b.toolThoughtSig,632+ Name: b.toolName,
633+ Arguments: json.RawMessage(args),
634+ ThoughtSignature: b.thoughtSignature,
611 })635 })
612 }636 }
613 }637 }
@@ -676,7 +700,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
676 for _, cand := range chunk.Candidates {700 for _, cand := range chunk.Candidates {
677 for _, part := range cand.Content.Parts {701 for _, part := range cand.Content.Parts {
678 if part.InlineData != nil {702 if part.InlineData != nil {
679- appendImage(part.InlineData.MimeType, part.InlineData.Data)703+ appendImage(part.InlineData.MimeType, part.InlineData.Data, part.ThoughtSignature)
680 continue704 continue
681 }705 }
682 if part.FunctionCall != nil {706 if part.FunctionCall != nil {
@@ -687,28 +711,22 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
687 args = json.RawMessage("{}")711 args = json.RawMessage("{}")
688 }712 }
689 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)713 t := startTool(part.FunctionCall.Name, part.FunctionCallID, args)
690- t.toolThoughtSig = part.ThoughtSignature714+ t.thoughtSignature = part.ThoughtSignature
691 out <- EventToolStart{ID: t.toolID, Name: t.toolName}715 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
692 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}716 out <- EventToolArgs{ID: t.toolID, Delta: t.toolArgs.String()}
693 out <- EventToolEnd{ID: t.toolID}717 out <- EventToolEnd{ID: t.toolID}
694 continue718 continue
695 }719 }
696- if part.Text == "" {
697- continue
698- }
699 if part.Thought {720 if part.Thought {
700- // Store thought parts as ReasoningBlocks so721+ appendPartText("reasoning", part.Text, part.ThoughtSignature)
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 = nil722 currentText = nil
709 continue723 continue
710 }724 }
711- appendText(part.Text)725+ if part.Text == "" {
726+ continue
727+ }
728+ appendPartText("text", part.Text, part.ThoughtSignature)
729+ currentReasoning = nil
712 out <- EventTextDelta{Delta: part.Text}730 out <- EventTextDelta{Delta: part.Text}
713 }731 }
714 switch cand.FinishReason {732 switch cand.FinishReason {
M packages/provider/gemini_test.go +69 -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).
@@ -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).
M packages/provider/provider.go +5 -5
@@ -28,15 +28,17 @@ 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() {}
@@ -46,8 +48,6 @@ type ToolCallBlock struct {
4648 ID string `json:"id"`
4749 Name string `json:"name"`
4850 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.
5151 ThoughtSignature string `json:"thought_signature,omitempty"`
5252 }
5353
@@ -28,15 +28,17 @@ 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() {}
@@ -46,8 +48,6 @@ 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"`
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"`51 ThoughtSignature string `json:"thought_signature,omitempty"`
52 }52 }
53 53

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