mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
Improve thinking/tool signature handling for Claude and Gemini requests
Prefer cached signatures and avoid injecting dummy thinking blocks; instead remove unsigned thinking blocks and add a skip sentinel for tool calls without a valid signature. Generate stable session IDs from the first user message, apply schema cleaning only for Claude models, and reorder thinking parts so thinking appears first. For Gemini, remove thinking blocks and attach a skip sentinel to function calls. Simplify response handling by passing raw function args through (remove special Bash conversion). Update and add tests to reflect the new behavior. These changes prevent rejected dummy signatures, improve compatibility with Antigravity’s signature validation, provide more stable session IDs for conversation grouping, and make request/response translation more robust.
This commit is contained in:
@@ -19,8 +19,6 @@ import (
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// deriveSessionID generates a stable session ID from the request.
|
||||
// Uses the hash of the first user message to identify the conversation.
|
||||
func deriveSessionID(rawJSON []byte) string {
|
||||
@@ -93,6 +91,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
// contents
|
||||
contentsJSON := "[]"
|
||||
hasContents := false
|
||||
|
||||
// Track if we need to disable thinking (LiteLLM approach)
|
||||
// If the last assistant message with tool_use has no valid thinking block before it,
|
||||
// we need to disable thinkingConfig to avoid "Expected thinking but found tool_use" error
|
||||
lastAssistantHasToolWithoutThinking := false
|
||||
|
||||
messagesResult := gjson.GetBytes(rawJSON, "messages")
|
||||
if messagesResult.IsArray() {
|
||||
messageResults := messagesResult.Array()
|
||||
@@ -114,6 +118,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
if contentsResult.IsArray() {
|
||||
contentResults := contentsResult.Array()
|
||||
numContents := len(contentResults)
|
||||
var currentMessageThinkingSignature string
|
||||
for j := 0; j < numContents; j++ {
|
||||
contentResult := contentResults[j]
|
||||
contentTypeResult := contentResult.Get("type")
|
||||
@@ -121,36 +126,46 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
// Use GetThinkingText to handle wrapped thinking objects
|
||||
thinkingText := util.GetThinkingText(contentResult)
|
||||
signatureResult := contentResult.Get("signature")
|
||||
signature := ""
|
||||
if signatureResult.Exists() && signatureResult.String() != "" {
|
||||
signature = signatureResult.String()
|
||||
}
|
||||
clientSignature := ""
|
||||
if signatureResult.Exists() && signatureResult.String() != "" {
|
||||
clientSignature = signatureResult.String()
|
||||
}
|
||||
|
||||
// Try to restore signature from cache for unsigned thinking blocks
|
||||
if !cache.HasValidSignature(signature) && sessionID != "" && thinkingText != "" {
|
||||
if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" {
|
||||
signature = cachedSig
|
||||
log.Debugf("Restored cached signature for thinking block")
|
||||
}
|
||||
// Always try cached signature first (more reliable than client-provided)
|
||||
// Client may send stale or invalid signatures from different sessions
|
||||
signature := ""
|
||||
if sessionID != "" && thinkingText != "" {
|
||||
if cachedSig := cache.GetCachedSignature(sessionID, thinkingText); cachedSig != "" {
|
||||
signature = cachedSig
|
||||
log.Debugf("Using cached signature for thinking block")
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to client signature only if cache miss and client signature is valid
|
||||
if signature == "" && cache.HasValidSignature(clientSignature) {
|
||||
signature = clientSignature
|
||||
log.Debugf("Using client-provided signature for thinking block")
|
||||
}
|
||||
|
||||
// Store for subsequent tool_use in the same message
|
||||
if cache.HasValidSignature(signature) {
|
||||
currentMessageThinkingSignature = signature
|
||||
}
|
||||
|
||||
|
||||
// Skip trailing unsigned thinking blocks on last assistant message
|
||||
isLastMessage := (i == numMessages-1)
|
||||
isLastContent := (j == numContents-1)
|
||||
isAssistant := (originalRole == "assistant")
|
||||
isUnsigned := !cache.HasValidSignature(signature)
|
||||
|
||||
if isLastMessage && isLastContent && isAssistant && isUnsigned {
|
||||
// Skip this trailing unsigned thinking block
|
||||
// If unsigned, skip entirely (don't convert to text)
|
||||
// Claude requires assistant messages to start with thinking blocks when thinking is enabled
|
||||
// Converting to text would break this requirement
|
||||
if isUnsigned {
|
||||
// TypeScript plugin approach: drop unsigned thinking blocks entirely
|
||||
log.Debugf("Dropping unsigned thinking block (no valid signature)")
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply sentinel for unsigned thinking blocks that are not trailing
|
||||
// (includes empty string and short/invalid signatures < 50 chars)
|
||||
if isUnsigned {
|
||||
signature = geminiCLIClaudeThoughtSignature
|
||||
}
|
||||
|
||||
// Valid signature, send as thought block
|
||||
partJSON := `{}`
|
||||
partJSON, _ = sjson.Set(partJSON, "thought", true)
|
||||
if thinkingText != "" {
|
||||
@@ -168,6 +183,10 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
}
|
||||
clientContentJSON, _ = sjson.SetRaw(clientContentJSON, "parts.-1", partJSON)
|
||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
|
||||
// NOTE: Do NOT inject dummy thinking blocks here.
|
||||
// Antigravity API validates signatures, so dummy values are rejected.
|
||||
// The TypeScript plugin removes unsigned thinking blocks instead of injecting dummies.
|
||||
|
||||
functionName := contentResult.Get("name").String()
|
||||
functionArgs := contentResult.Get("input").String()
|
||||
functionID := contentResult.Get("id").String()
|
||||
@@ -175,9 +194,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
argsResult := gjson.Parse(functionArgs)
|
||||
if argsResult.IsObject() {
|
||||
partJSON := `{}`
|
||||
if !strings.Contains(modelName, "claude") {
|
||||
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", geminiCLIClaudeThoughtSignature)
|
||||
|
||||
// Use skip_thought_signature_validator for tool calls without valid thinking signature
|
||||
// This is the approach used in opencode-google-antigravity-auth for Gemini
|
||||
// and also works for Claude through Antigravity API
|
||||
const skipSentinel = "skip_thought_signature_validator"
|
||||
if cache.HasValidSignature(currentMessageThinkingSignature) {
|
||||
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", currentMessageThinkingSignature)
|
||||
} else {
|
||||
// No valid signature - use skip sentinel to bypass validation
|
||||
partJSON, _ = sjson.Set(partJSON, "thoughtSignature", skipSentinel)
|
||||
}
|
||||
|
||||
if functionID != "" {
|
||||
partJSON, _ = sjson.Set(partJSON, "functionCall.id", functionID)
|
||||
}
|
||||
@@ -239,6 +267,64 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder parts for 'model' role to ensure thinking block is first
|
||||
if role == "model" {
|
||||
partsResult := gjson.Get(clientContentJSON, "parts")
|
||||
if partsResult.IsArray() {
|
||||
parts := partsResult.Array()
|
||||
var thinkingParts []gjson.Result
|
||||
var otherParts []gjson.Result
|
||||
for _, part := range parts {
|
||||
if part.Get("thought").Bool() {
|
||||
thinkingParts = append(thinkingParts, part)
|
||||
} else {
|
||||
otherParts = append(otherParts, part)
|
||||
}
|
||||
}
|
||||
if len(thinkingParts) > 0 {
|
||||
firstPartIsThinking := parts[0].Get("thought").Bool()
|
||||
if !firstPartIsThinking || len(thinkingParts) > 1 {
|
||||
var newParts []interface{}
|
||||
for _, p := range thinkingParts {
|
||||
newParts = append(newParts, p.Value())
|
||||
}
|
||||
for _, p := range otherParts {
|
||||
newParts = append(newParts, p.Value())
|
||||
}
|
||||
clientContentJSON, _ = sjson.Set(clientContentJSON, "parts", newParts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this assistant message has tool_use without valid thinking
|
||||
if role == "model" {
|
||||
partsResult := gjson.Get(clientContentJSON, "parts")
|
||||
if partsResult.IsArray() {
|
||||
parts := partsResult.Array()
|
||||
hasValidThinking := false
|
||||
hasToolUse := false
|
||||
|
||||
for _, part := range parts {
|
||||
if part.Get("thought").Bool() {
|
||||
hasValidThinking = true
|
||||
}
|
||||
if part.Get("functionCall").Exists() {
|
||||
hasToolUse = true
|
||||
}
|
||||
}
|
||||
|
||||
// If this message has tool_use but no valid thinking, mark it
|
||||
// This will be used to disable thinking mode if needed
|
||||
if hasToolUse && !hasValidThinking {
|
||||
lastAssistantHasToolWithoutThinking = true
|
||||
} else {
|
||||
lastAssistantHasToolWithoutThinking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentsJSON, _ = sjson.SetRaw(contentsJSON, "-1", clientContentJSON)
|
||||
hasContents = true
|
||||
} else if contentsResult.Type == gjson.String {
|
||||
@@ -333,6 +419,13 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
||||
out, _ = sjson.Set(out, "request.generationConfig.maxOutputTokens", v.Num)
|
||||
}
|
||||
|
||||
// Note: We do NOT drop thinkingConfig here anymore.
|
||||
// Instead, we:
|
||||
// 1. Remove unsigned thinking blocks (done during message processing)
|
||||
// 2. Add skip_thought_signature_validator to tool_use without valid thinking signature
|
||||
// This approach keeps thinking mode enabled while handling the signature requirements.
|
||||
_ = lastAssistantHasToolWithoutThinking // Variable is tracked but not used to drop thinkingConfig
|
||||
|
||||
outBytes := []byte(out)
|
||||
outBytes = common.AttachDefaultSafetySettings(outBytes, "request.safetySettings")
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
|
||||
// Unsigned thinking blocks should be removed entirely (not converted to text)
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-sonnet-4-5-thinking",
|
||||
"messages": [
|
||||
@@ -121,11 +122,18 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *test
|
||||
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||
outputStr := string(output)
|
||||
|
||||
// Without signature, should use sentinel value
|
||||
firstPart := gjson.Get(outputStr, "request.contents.0.parts.0")
|
||||
if firstPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature {
|
||||
t.Errorf("Expected sentinel signature '%s', got '%s'",
|
||||
geminiCLIClaudeThoughtSignature, firstPart.Get("thoughtSignature").String())
|
||||
// Without signature, thinking block should be removed (not converted to text)
|
||||
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
|
||||
}
|
||||
|
||||
// Only text part should remain
|
||||
if parts[0].Get("thought").Bool() {
|
||||
t.Error("Thinking block should be removed, not preserved")
|
||||
}
|
||||
if parts[0].Get("text").String() != "Answer" {
|
||||
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +200,16 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
|
||||
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
||||
outputStr := string(output)
|
||||
|
||||
// Check function call conversion
|
||||
funcCall := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall")
|
||||
// Now we expect only 1 part (tool_use), no dummy thinking block injected
|
||||
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("Expected 1 part (tool only, no dummy injection), got %d", len(parts))
|
||||
}
|
||||
|
||||
// Check function call conversion at parts[0]
|
||||
funcCall := parts[0].Get("functionCall")
|
||||
if !funcCall.Exists() {
|
||||
t.Error("functionCall should exist")
|
||||
t.Error("functionCall should exist at parts[0]")
|
||||
}
|
||||
if funcCall.Get("name").String() != "get_weather" {
|
||||
t.Errorf("Expected function name 'get_weather', got '%s'", funcCall.Get("name").String())
|
||||
@@ -203,6 +217,78 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
|
||||
if funcCall.Get("id").String() != "call_123" {
|
||||
t.Errorf("Expected function id 'call_123', got '%s'", funcCall.Get("id").String())
|
||||
}
|
||||
// Verify skip_thought_signature_validator is added (bypass for tools without valid thinking)
|
||||
expectedSig := "skip_thought_signature_validator"
|
||||
actualSig := parts[0].Get("thoughtSignature").String()
|
||||
if actualSig != expectedSig {
|
||||
t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, actualSig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
|
||||
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-sonnet-4-5-thinking",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me think...", "signature": "` + validSignature + `"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "call_123",
|
||||
"name": "get_weather",
|
||||
"input": "{\"location\": \"Paris\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||
outputStr := string(output)
|
||||
|
||||
// Check function call has the signature from the preceding thinking block
|
||||
part := gjson.Get(outputStr, "request.contents.0.parts.1")
|
||||
if part.Get("functionCall.name").String() != "get_weather" {
|
||||
t.Errorf("Expected functionCall, got %s", part.Raw)
|
||||
}
|
||||
if part.Get("thoughtSignature").String() != validSignature {
|
||||
t.Errorf("Expected thoughtSignature '%s' on tool_use, got '%s'", validSignature, part.Get("thoughtSignature").String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
|
||||
// Case: text block followed by thinking block -> should be reordered to thinking first
|
||||
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-sonnet-4-5-thinking",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Here is the plan."},
|
||||
{"type": "thinking", "thinking": "Planning...", "signature": "` + validSignature + `"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||
outputStr := string(output)
|
||||
|
||||
// Verify order: Thinking block MUST be first
|
||||
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("Expected 2 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
if !parts[0].Get("thought").Bool() {
|
||||
t.Error("First part should be thinking block after reordering")
|
||||
}
|
||||
if parts[1].Get("text").String() != "Here is the plan." {
|
||||
t.Error("Second part should be text block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) {
|
||||
@@ -402,8 +488,8 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplied(t *testing.T) {
|
||||
// Middle message has unsigned thinking - should use sentinel (existing behavior)
|
||||
func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_Removed(t *testing.T) {
|
||||
// Middle message has unsigned thinking - should be removed entirely
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-sonnet-4-5-thinking",
|
||||
"messages": [
|
||||
@@ -424,13 +510,18 @@ func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_SentinelApplie
|
||||
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
||||
outputStr := string(output)
|
||||
|
||||
// Middle unsigned thinking should have sentinel applied
|
||||
thinkingPart := gjson.Get(outputStr, "request.contents.0.parts.0")
|
||||
if !thinkingPart.Get("thought").Bool() {
|
||||
t.Error("Middle thinking block should be preserved with sentinel")
|
||||
// Unsigned thinking should be removed entirely
|
||||
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
|
||||
}
|
||||
if thinkingPart.Get("thoughtSignature").String() != geminiCLIClaudeThoughtSignature {
|
||||
t.Errorf("Middle unsigned thinking should use sentinel signature, got: %s", thinkingPart.Get("thoughtSignature").String())
|
||||
|
||||
// Only text part should remain
|
||||
if parts[0].Get("thought").Bool() {
|
||||
t.Error("Thinking block should be removed, not preserved")
|
||||
}
|
||||
if parts[0].Get("text").String() != "Answer" {
|
||||
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -253,13 +253,8 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
||||
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
argsRaw := fcArgsResult.Raw
|
||||
// Convert command → cmd for Bash tools using proper JSON parsing
|
||||
if fcName == "Bash" || fcName == "bash" || fcName == "bash_20241022" {
|
||||
argsRaw = convertBashCommandToCmdField(argsRaw)
|
||||
}
|
||||
output = output + "event: content_block_delta\n"
|
||||
data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", argsRaw)
|
||||
data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex), "delta.partial_json", fcArgsResult.Raw)
|
||||
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||
}
|
||||
params.ResponseType = 3
|
||||
@@ -347,36 +342,6 @@ func resolveStopReason(params *Params) string {
|
||||
return "end_turn"
|
||||
}
|
||||
|
||||
// convertBashCommandToCmdField converts "command" field to "cmd" field for Bash tools.
|
||||
// Amp expects "cmd" but Gemini sends "command". This uses proper JSON parsing
|
||||
// to avoid accidentally replacing "command" that appears in values.
|
||||
func convertBashCommandToCmdField(argsRaw string) string {
|
||||
// Only process valid JSON
|
||||
if !gjson.Valid(argsRaw) {
|
||||
return argsRaw
|
||||
}
|
||||
|
||||
// Check if "command" key exists and "cmd" doesn't
|
||||
commandVal := gjson.Get(argsRaw, "command")
|
||||
cmdVal := gjson.Get(argsRaw, "cmd")
|
||||
|
||||
if commandVal.Exists() && !cmdVal.Exists() {
|
||||
// Set "cmd" to the value of "command", preserve the raw value type
|
||||
result, err := sjson.SetRaw(argsRaw, "cmd", commandVal.Raw)
|
||||
if err != nil {
|
||||
return argsRaw
|
||||
}
|
||||
// Delete "command" key
|
||||
result, err = sjson.Delete(result, "command")
|
||||
if err != nil {
|
||||
return argsRaw
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return argsRaw
|
||||
}
|
||||
|
||||
// ConvertAntigravityResponseToClaudeNonStream converts a non-streaming Gemini CLI response to a non-streaming Claude response.
|
||||
//
|
||||
// Parameters:
|
||||
@@ -488,12 +453,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
|
||||
toolBlock, _ = sjson.Set(toolBlock, "name", name)
|
||||
|
||||
if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) {
|
||||
argsRaw := args.Raw
|
||||
// Convert command → cmd for Bash tools
|
||||
if name == "Bash" || name == "bash" || name == "bash_20241022" {
|
||||
argsRaw = convertBashCommandToCmdField(argsRaw)
|
||||
}
|
||||
toolBlock, _ = sjson.SetRaw(toolBlock, "input", argsRaw)
|
||||
toolBlock, _ = sjson.SetRaw(toolBlock, "input", args.Raw)
|
||||
}
|
||||
|
||||
ensureContentArray()
|
||||
|
||||
@@ -8,79 +8,6 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
||||
)
|
||||
|
||||
func TestConvertBashCommandToCmdField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "basic command to cmd conversion",
|
||||
input: `{"command": "git diff"}`,
|
||||
expected: `{"cmd":"git diff"}`,
|
||||
},
|
||||
{
|
||||
name: "already has cmd field - no change",
|
||||
input: `{"cmd": "git diff"}`,
|
||||
expected: `{"cmd": "git diff"}`,
|
||||
},
|
||||
{
|
||||
name: "both cmd and command - keep cmd only",
|
||||
input: `{"command": "git diff", "cmd": "ls"}`,
|
||||
expected: `{"command": "git diff", "cmd": "ls"}`, // no change when cmd exists
|
||||
},
|
||||
{
|
||||
name: "command with special characters in value",
|
||||
input: `{"command": "echo \"command\": test"}`,
|
||||
expected: `{"cmd":"echo \"command\": test"}`,
|
||||
},
|
||||
{
|
||||
name: "command with nested quotes",
|
||||
input: `{"command": "bash -c 'echo \"hello\"'"}`,
|
||||
expected: `{"cmd":"bash -c 'echo \"hello\"'"}`,
|
||||
},
|
||||
{
|
||||
name: "command with newlines",
|
||||
input: `{"command": "echo hello\necho world"}`,
|
||||
expected: `{"cmd":"echo hello\necho world"}`,
|
||||
},
|
||||
{
|
||||
name: "empty command value",
|
||||
input: `{"command": ""}`,
|
||||
expected: `{"cmd":""}`,
|
||||
},
|
||||
{
|
||||
name: "command with other fields - preserves them",
|
||||
input: `{"command": "git diff", "timeout": 30}`,
|
||||
expected: `{ "timeout": 30,"cmd":"git diff"}`,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON - returns unchanged",
|
||||
input: `{invalid json`,
|
||||
expected: `{invalid json`,
|
||||
},
|
||||
{
|
||||
name: "empty object",
|
||||
input: `{}`,
|
||||
expected: `{}`,
|
||||
},
|
||||
{
|
||||
name: "no command field",
|
||||
input: `{"restart": true}`,
|
||||
expected: `{"restart": true}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := convertBashCommandToCmdField(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("convertBashCommandToCmdField(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Signature Caching Tests
|
||||
// ============================================================================
|
||||
@@ -354,7 +281,7 @@ func TestDeriveSessionIDFromRequest(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := deriveSessionIDFromRequest(tt.input)
|
||||
result := deriveSessionID(tt.input)
|
||||
if tt.wantEmpty && result != "" {
|
||||
t.Errorf("Expected empty session ID, got '%s'", result)
|
||||
}
|
||||
@@ -368,8 +295,8 @@ func TestDeriveSessionIDFromRequest(t *testing.T) {
|
||||
func TestDeriveSessionIDFromRequest_Deterministic(t *testing.T) {
|
||||
input := []byte(`{"messages": [{"role": "user", "content": "Same message"}]}`)
|
||||
|
||||
id1 := deriveSessionIDFromRequest(input)
|
||||
id2 := deriveSessionIDFromRequest(input)
|
||||
id1 := deriveSessionID(input)
|
||||
id2 := deriveSessionID(input)
|
||||
|
||||
if id1 != id2 {
|
||||
t.Errorf("Session ID should be deterministic: '%s' != '%s'", id1, id2)
|
||||
@@ -380,8 +307,8 @@ func TestDeriveSessionIDFromRequest_DifferentMessages(t *testing.T) {
|
||||
input1 := []byte(`{"messages": [{"role": "user", "content": "Message A"}]}`)
|
||||
input2 := []byte(`{"messages": [{"role": "user", "content": "Message B"}]}`)
|
||||
|
||||
id1 := deriveSessionIDFromRequest(input1)
|
||||
id2 := deriveSessionIDFromRequest(input2)
|
||||
id1 := deriveSessionID(input1)
|
||||
id2 := deriveSessionID(input2)
|
||||
|
||||
if id1 == id2 {
|
||||
t.Error("Different messages should produce different session IDs")
|
||||
|
||||
Reference in New Issue
Block a user