Skip to content

Commit 6390886

Browse files
authored
Merge pull request #611 from soilSpoon/feature/antigravity
feat(antigravity): Improve Claude model compatibility
2 parents f6d6251 + 7dc40ba commit 6390886

14 files changed

+2162
-80
lines changed

internal/cache/signature_cache.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package cache
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"sort"
7+
"sync"
8+
"time"
9+
)
10+
11+
// SignatureEntry holds a cached thinking signature with timestamp
12+
type SignatureEntry struct {
13+
Signature string
14+
Timestamp time.Time
15+
}
16+
17+
const (
18+
// SignatureCacheTTL is how long signatures are valid
19+
SignatureCacheTTL = 1 * time.Hour
20+
21+
// MaxEntriesPerSession limits memory usage per session
22+
MaxEntriesPerSession = 100
23+
24+
// SignatureTextHashLen is the length of the hash key (16 hex chars = 64-bit key space)
25+
SignatureTextHashLen = 16
26+
27+
// MinValidSignatureLen is the minimum length for a signature to be considered valid
28+
MinValidSignatureLen = 50
29+
)
30+
31+
// signatureCache stores signatures by sessionId -> textHash -> SignatureEntry
32+
var signatureCache sync.Map
33+
34+
// sessionCache is the inner map type
35+
type sessionCache struct {
36+
mu sync.RWMutex
37+
entries map[string]SignatureEntry
38+
}
39+
40+
// hashText creates a stable, Unicode-safe key from text content
41+
func hashText(text string) string {
42+
h := sha256.Sum256([]byte(text))
43+
return hex.EncodeToString(h[:])[:SignatureTextHashLen]
44+
}
45+
46+
// getOrCreateSession gets or creates a session cache
47+
func getOrCreateSession(sessionID string) *sessionCache {
48+
if val, ok := signatureCache.Load(sessionID); ok {
49+
return val.(*sessionCache)
50+
}
51+
sc := &sessionCache{entries: make(map[string]SignatureEntry)}
52+
actual, _ := signatureCache.LoadOrStore(sessionID, sc)
53+
return actual.(*sessionCache)
54+
}
55+
56+
// CacheSignature stores a thinking signature for a given session and text.
57+
// Used for Claude models that require signed thinking blocks in multi-turn conversations.
58+
func CacheSignature(sessionID, text, signature string) {
59+
if sessionID == "" || text == "" || signature == "" {
60+
return
61+
}
62+
if len(signature) < MinValidSignatureLen {
63+
return
64+
}
65+
66+
sc := getOrCreateSession(sessionID)
67+
textHash := hashText(text)
68+
69+
sc.mu.Lock()
70+
defer sc.mu.Unlock()
71+
72+
// Evict expired entries if at capacity
73+
if len(sc.entries) >= MaxEntriesPerSession {
74+
now := time.Now()
75+
for key, entry := range sc.entries {
76+
if now.Sub(entry.Timestamp) > SignatureCacheTTL {
77+
delete(sc.entries, key)
78+
}
79+
}
80+
// If still at capacity, remove oldest entries
81+
if len(sc.entries) >= MaxEntriesPerSession {
82+
// Find and remove oldest quarter
83+
oldest := make([]struct {
84+
key string
85+
ts time.Time
86+
}, 0, len(sc.entries))
87+
for key, entry := range sc.entries {
88+
oldest = append(oldest, struct {
89+
key string
90+
ts time.Time
91+
}{key, entry.Timestamp})
92+
}
93+
// Sort by timestamp (oldest first) using sort.Slice
94+
sort.Slice(oldest, func(i, j int) bool {
95+
return oldest[i].ts.Before(oldest[j].ts)
96+
})
97+
98+
toRemove := len(oldest) / 4
99+
if toRemove < 1 {
100+
toRemove = 1
101+
}
102+
103+
for i := 0; i < toRemove; i++ {
104+
delete(sc.entries, oldest[i].key)
105+
}
106+
}
107+
}
108+
109+
sc.entries[textHash] = SignatureEntry{
110+
Signature: signature,
111+
Timestamp: time.Now(),
112+
}
113+
}
114+
115+
// GetCachedSignature retrieves a cached signature for a given session and text.
116+
// Returns empty string if not found or expired.
117+
func GetCachedSignature(sessionID, text string) string {
118+
if sessionID == "" || text == "" {
119+
return ""
120+
}
121+
122+
val, ok := signatureCache.Load(sessionID)
123+
if !ok {
124+
return ""
125+
}
126+
sc := val.(*sessionCache)
127+
128+
textHash := hashText(text)
129+
130+
sc.mu.RLock()
131+
entry, exists := sc.entries[textHash]
132+
sc.mu.RUnlock()
133+
134+
if !exists {
135+
return ""
136+
}
137+
138+
// Check if expired
139+
if time.Since(entry.Timestamp) > SignatureCacheTTL {
140+
sc.mu.Lock()
141+
delete(sc.entries, textHash)
142+
sc.mu.Unlock()
143+
return ""
144+
}
145+
146+
return entry.Signature
147+
}
148+
149+
// ClearSignatureCache clears signature cache for a specific session or all sessions.
150+
func ClearSignatureCache(sessionID string) {
151+
if sessionID != "" {
152+
signatureCache.Delete(sessionID)
153+
} else {
154+
signatureCache.Range(func(key, _ any) bool {
155+
signatureCache.Delete(key)
156+
return true
157+
})
158+
}
159+
}
160+
161+
// HasValidSignature checks if a signature is valid (non-empty and long enough)
162+
func HasValidSignature(signature string) bool {
163+
return signature != "" && len(signature) >= MinValidSignatureLen
164+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package cache
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestCacheSignature_BasicStorageAndRetrieval(t *testing.T) {
9+
ClearSignatureCache("")
10+
11+
sessionID := "test-session-1"
12+
text := "This is some thinking text content"
13+
signature := "abc123validSignature1234567890123456789012345678901234567890"
14+
15+
// Store signature
16+
CacheSignature(sessionID, text, signature)
17+
18+
// Retrieve signature
19+
retrieved := GetCachedSignature(sessionID, text)
20+
if retrieved != signature {
21+
t.Errorf("Expected signature '%s', got '%s'", signature, retrieved)
22+
}
23+
}
24+
25+
func TestCacheSignature_DifferentSessions(t *testing.T) {
26+
ClearSignatureCache("")
27+
28+
text := "Same text in different sessions"
29+
sig1 := "signature1_1234567890123456789012345678901234567890123456"
30+
sig2 := "signature2_1234567890123456789012345678901234567890123456"
31+
32+
CacheSignature("session-a", text, sig1)
33+
CacheSignature("session-b", text, sig2)
34+
35+
if GetCachedSignature("session-a", text) != sig1 {
36+
t.Error("Session-a signature mismatch")
37+
}
38+
if GetCachedSignature("session-b", text) != sig2 {
39+
t.Error("Session-b signature mismatch")
40+
}
41+
}
42+
43+
func TestCacheSignature_NotFound(t *testing.T) {
44+
ClearSignatureCache("")
45+
46+
// Non-existent session
47+
if got := GetCachedSignature("nonexistent", "some text"); got != "" {
48+
t.Errorf("Expected empty string for nonexistent session, got '%s'", got)
49+
}
50+
51+
// Existing session but different text
52+
CacheSignature("session-x", "text-a", "sigA12345678901234567890123456789012345678901234567890")
53+
if got := GetCachedSignature("session-x", "text-b"); got != "" {
54+
t.Errorf("Expected empty string for different text, got '%s'", got)
55+
}
56+
}
57+
58+
func TestCacheSignature_EmptyInputs(t *testing.T) {
59+
ClearSignatureCache("")
60+
61+
// All empty/invalid inputs should be no-ops
62+
CacheSignature("", "text", "sig12345678901234567890123456789012345678901234567890")
63+
CacheSignature("session", "", "sig12345678901234567890123456789012345678901234567890")
64+
CacheSignature("session", "text", "")
65+
CacheSignature("session", "text", "short") // Too short
66+
67+
if got := GetCachedSignature("session", "text"); got != "" {
68+
t.Errorf("Expected empty after invalid cache attempts, got '%s'", got)
69+
}
70+
}
71+
72+
func TestCacheSignature_ShortSignatureRejected(t *testing.T) {
73+
ClearSignatureCache("")
74+
75+
sessionID := "test-short-sig"
76+
text := "Some text"
77+
shortSig := "abc123" // Less than 50 chars
78+
79+
CacheSignature(sessionID, text, shortSig)
80+
81+
if got := GetCachedSignature(sessionID, text); got != "" {
82+
t.Errorf("Short signature should be rejected, got '%s'", got)
83+
}
84+
}
85+
86+
func TestClearSignatureCache_SpecificSession(t *testing.T) {
87+
ClearSignatureCache("")
88+
89+
sig := "validSig1234567890123456789012345678901234567890123456"
90+
CacheSignature("session-1", "text", sig)
91+
CacheSignature("session-2", "text", sig)
92+
93+
ClearSignatureCache("session-1")
94+
95+
if got := GetCachedSignature("session-1", "text"); got != "" {
96+
t.Error("session-1 should be cleared")
97+
}
98+
if got := GetCachedSignature("session-2", "text"); got != sig {
99+
t.Error("session-2 should still exist")
100+
}
101+
}
102+
103+
func TestClearSignatureCache_AllSessions(t *testing.T) {
104+
ClearSignatureCache("")
105+
106+
sig := "validSig1234567890123456789012345678901234567890123456"
107+
CacheSignature("session-1", "text", sig)
108+
CacheSignature("session-2", "text", sig)
109+
110+
ClearSignatureCache("")
111+
112+
if got := GetCachedSignature("session-1", "text"); got != "" {
113+
t.Error("session-1 should be cleared")
114+
}
115+
if got := GetCachedSignature("session-2", "text"); got != "" {
116+
t.Error("session-2 should be cleared")
117+
}
118+
}
119+
120+
func TestHasValidSignature(t *testing.T) {
121+
tests := []struct {
122+
name string
123+
signature string
124+
expected bool
125+
}{
126+
{"valid long signature", "abc123validSignature1234567890123456789012345678901234567890", true},
127+
{"exactly 50 chars", "12345678901234567890123456789012345678901234567890", true},
128+
{"49 chars - invalid", "1234567890123456789012345678901234567890123456789", false},
129+
{"empty string", "", false},
130+
{"short signature", "abc", false},
131+
}
132+
133+
for _, tt := range tests {
134+
t.Run(tt.name, func(t *testing.T) {
135+
result := HasValidSignature(tt.signature)
136+
if result != tt.expected {
137+
t.Errorf("HasValidSignature(%q) = %v, expected %v", tt.signature, result, tt.expected)
138+
}
139+
})
140+
}
141+
}
142+
143+
func TestCacheSignature_TextHashCollisionResistance(t *testing.T) {
144+
ClearSignatureCache("")
145+
146+
sessionID := "hash-test-session"
147+
148+
// Different texts should produce different hashes
149+
text1 := "First thinking text"
150+
text2 := "Second thinking text"
151+
sig1 := "signature1_1234567890123456789012345678901234567890123456"
152+
sig2 := "signature2_1234567890123456789012345678901234567890123456"
153+
154+
CacheSignature(sessionID, text1, sig1)
155+
CacheSignature(sessionID, text2, sig2)
156+
157+
if GetCachedSignature(sessionID, text1) != sig1 {
158+
t.Error("text1 signature mismatch")
159+
}
160+
if GetCachedSignature(sessionID, text2) != sig2 {
161+
t.Error("text2 signature mismatch")
162+
}
163+
}
164+
165+
func TestCacheSignature_UnicodeText(t *testing.T) {
166+
ClearSignatureCache("")
167+
168+
sessionID := "unicode-session"
169+
text := "한글 텍스트와 이모지 🎉 그리고 特殊文字"
170+
sig := "unicodeSig123456789012345678901234567890123456789012345"
171+
172+
CacheSignature(sessionID, text, sig)
173+
174+
if got := GetCachedSignature(sessionID, text); got != sig {
175+
t.Errorf("Unicode text signature retrieval failed, got '%s'", got)
176+
}
177+
}
178+
179+
func TestCacheSignature_Overwrite(t *testing.T) {
180+
ClearSignatureCache("")
181+
182+
sessionID := "overwrite-session"
183+
text := "Same text"
184+
sig1 := "firstSignature12345678901234567890123456789012345678901"
185+
sig2 := "secondSignature1234567890123456789012345678901234567890"
186+
187+
CacheSignature(sessionID, text, sig1)
188+
CacheSignature(sessionID, text, sig2) // Overwrite
189+
190+
if got := GetCachedSignature(sessionID, text); got != sig2 {
191+
t.Errorf("Expected overwritten signature '%s', got '%s'", sig2, got)
192+
}
193+
}
194+
195+
// Note: TTL expiration test is tricky to test without mocking time
196+
// We test the logic path exists but actual expiration would require time manipulation
197+
func TestCacheSignature_ExpirationLogic(t *testing.T) {
198+
ClearSignatureCache("")
199+
200+
// This test verifies the expiration check exists
201+
// In a real scenario, we'd mock time.Now()
202+
sessionID := "expiration-test"
203+
text := "text"
204+
sig := "validSig1234567890123456789012345678901234567890123456"
205+
206+
CacheSignature(sessionID, text, sig)
207+
208+
// Fresh entry should be retrievable
209+
if got := GetCachedSignature(sessionID, text); got != sig {
210+
t.Errorf("Fresh entry should be retrievable, got '%s'", got)
211+
}
212+
213+
// We can't easily test actual expiration without time mocking
214+
// but the logic is verified by the implementation
215+
_ = time.Now() // Acknowledge we're not testing time passage
216+
}

0 commit comments

Comments
 (0)