Files
kirogo/proxy/translator_test.go
huangzhenpc c87517c0bf
Some checks failed
Build Docker Image / build (push) Has been cancelled
feat: sanitize injection blocks from conversation history before forwarding upstream
When the proxy's own --- SYSTEM PROMPT --- wrapper or Claude Code's
<system-reminder> blocks appear in conversation history (e.g. echoed
back by Kiro and included in the next request), strip them from user
and assistant message content before building the Kiro payload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 10:58:19 +08:00

385 lines
13 KiB
Go

package proxy
import (
"strings"
"testing"
)
func TestExtractOpenAIMessageTextStructured(t *testing.T) {
content := []interface{}{
map[string]interface{}{"type": "text", "text": "alpha"},
map[string]interface{}{"type": "input_text", "text": "beta"},
}
if got := extractOpenAIMessageText(content); got != "alphabeta" {
t.Fatalf("expected concatenated structured text, got %q", got)
}
nested := map[string]interface{}{
"content": []interface{}{map[string]interface{}{"type": "text", "text": "nested"}},
}
if got := extractOpenAIMessageText(nested); got != "nested" {
t.Fatalf("expected nested content extraction, got %q", got)
}
}
func TestOpenAIToKiroPreservesStructuredAssistantAndToolContent(t *testing.T) {
req := &OpenAIRequest{
Model: "claude-sonnet-4.5",
Messages: []OpenAIMessage{
{
Role: "system",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "system-a"},
map[string]interface{}{"type": "text", "text": "system-b"},
},
},
{Role: "user", Content: "first-question"},
{
Role: "assistant",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "assistant-structured"},
},
},
{
Role: "tool",
ToolCallID: "call_1",
Content: []interface{}{
map[string]interface{}{"type": "text", "text": "tool-result-structured"},
},
},
},
}
payload := OpenAIToKiro(req, false)
if len(payload.ConversationState.History) != 2 {
t.Fatalf("expected 2 history items, got %d", len(payload.ConversationState.History))
}
firstHistoryUser := payload.ConversationState.History[0].UserInputMessage
if firstHistoryUser == nil {
t.Fatalf("expected first history item to be user message")
}
if !strings.Contains(firstHistoryUser.Content, "system-a") ||
!strings.Contains(firstHistoryUser.Content, "system-b") ||
!strings.Contains(firstHistoryUser.Content, "first-question") {
t.Fatalf("expected merged system+user content, got %q", firstHistoryUser.Content)
}
historyAssistant := payload.ConversationState.History[1].AssistantResponseMessage
if historyAssistant == nil {
t.Fatalf("expected second history item to be assistant message")
}
if historyAssistant.Content != "assistant-structured" {
t.Fatalf("expected assistant structured content to be preserved, got %q", historyAssistant.Content)
}
cur := payload.ConversationState.CurrentMessage.UserInputMessage
if !strings.Contains(cur.Content, "tool-result-structured") {
t.Fatalf("expected tool-result continuation content, got %q", cur.Content)
}
if cur.UserInputMessageContext == nil || len(cur.UserInputMessageContext.ToolResults) != 1 {
t.Fatalf("expected one tool result in current context")
}
gotToolText := cur.UserInputMessageContext.ToolResults[0].Content[0].Text
if gotToolText != "tool-result-structured" {
t.Fatalf("expected structured tool result text, got %q", gotToolText)
}
}
func TestOpenAIToKiroAssistantMapContentInHistory(t *testing.T) {
req := &OpenAIRequest{
Model: "claude-sonnet-4.5",
Messages: []OpenAIMessage{
{Role: "user", Content: "u1"},
{Role: "assistant", Content: map[string]interface{}{"type": "text", "text": "assistant-map"}},
{Role: "user", Content: "u2"},
},
}
payload := OpenAIToKiro(req, false)
if len(payload.ConversationState.History) != 2 {
t.Fatalf("expected 2 history entries, got %d", len(payload.ConversationState.History))
}
assistant := payload.ConversationState.History[1].AssistantResponseMessage
if assistant == nil {
t.Fatalf("expected second history entry to be assistant")
}
if assistant.Content != "assistant-map" {
t.Fatalf("expected assistant map content preserved, got %q", assistant.Content)
}
}
func TestOpenAIToKiroAssistantToolCallsDoNotInjectPlaceholder(t *testing.T) {
req := &OpenAIRequest{
Model: "claude-sonnet-4.5",
Messages: []OpenAIMessage{
{Role: "user", Content: "find weather"},
{
Role: "assistant",
Content: nil,
ToolCalls: []ToolCall{{
ID: "call_1",
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: "get_weather", Arguments: "{}"},
}},
},
{Role: "user", Content: "continue"},
},
}
payload := OpenAIToKiro(req, false)
if len(payload.ConversationState.History) < 2 {
t.Fatalf("expected history with assistant tool call")
}
assistant := payload.ConversationState.History[1].AssistantResponseMessage
if assistant == nil {
t.Fatalf("expected assistant history entry")
}
if assistant.Content != "" {
t.Fatalf("expected empty assistant content for tool-call-only turn, got %q", assistant.Content)
}
}
func TestOpenAIConversationIDStableFromAnchor(t *testing.T) {
baseMessages := []OpenAIMessage{
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Build calculator"},
{Role: "assistant", Content: "Sure"},
{Role: "user", Content: "Continue"},
}
reqA := &OpenAIRequest{Model: "claude-sonnet-4.5", Messages: baseMessages}
reqB := &OpenAIRequest{Model: "claude-sonnet-4.5", Messages: append(baseMessages, OpenAIMessage{Role: "assistant", Content: "Next step"})}
payloadA := OpenAIToKiro(reqA, false)
payloadB := OpenAIToKiro(reqB, false)
if payloadA.ConversationState.ConversationID == "" || payloadB.ConversationState.ConversationID == "" {
t.Fatalf("expected non-empty conversation IDs")
}
if payloadA.ConversationState.ConversationID != payloadB.ConversationState.ConversationID {
t.Fatalf("expected stable conversation ID across turns, got %q vs %q", payloadA.ConversationState.ConversationID, payloadB.ConversationState.ConversationID)
}
}
func TestClaudeConversationIDStableFromAnchor(t *testing.T) {
reqA := &ClaudeRequest{
Model: "claude-sonnet-4.5",
System: "sys",
Messages: []ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
reqB := &ClaudeRequest{
Model: "claude-sonnet-4.5",
System: "sys",
Messages: []ClaudeMessage{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "next"},
},
}
payloadA := ClaudeToKiro(reqA, false)
payloadB := ClaudeToKiro(reqB, false)
if payloadA.ConversationState.ConversationID == "" || payloadB.ConversationState.ConversationID == "" {
t.Fatalf("expected non-empty conversation IDs")
}
if payloadA.ConversationState.ConversationID != payloadB.ConversationState.ConversationID {
t.Fatalf("expected stable conversation ID across turns, got %q vs %q", payloadA.ConversationState.ConversationID, payloadB.ConversationState.ConversationID)
}
}
func TestOpenAIConversationIDRandomForSyntheticAnchor(t *testing.T) {
req := &OpenAIRequest{
Model: "claude-sonnet-4.5",
Messages: []OpenAIMessage{
{Role: "assistant", Content: "prefill"},
},
}
payloadA := OpenAIToKiro(req, false)
payloadB := OpenAIToKiro(req, false)
if payloadA.ConversationState.ConversationID == payloadB.ConversationState.ConversationID {
t.Fatalf("expected synthetic anchor to generate non-deterministic conversation IDs")
}
}
func TestClaudeToKiroDropsLeadingAssistantHistory(t *testing.T) {
req := &ClaudeRequest{
Model: "claude-sonnet-4.5",
Messages: []ClaudeMessage{
{Role: "assistant", Content: "prefill"},
{Role: "user", Content: "real user message"},
},
}
payload := ClaudeToKiro(req, false)
if len(payload.ConversationState.History) != 0 {
t.Fatalf("expected leading assistant-only history to be dropped, got %d entries", len(payload.ConversationState.History))
}
if strings.Contains(payload.ConversationState.CurrentMessage.UserInputMessage.Content, "Begin conversation") {
t.Fatalf("unexpected synthetic Begin conversation injection in current content: %q", payload.ConversationState.CurrentMessage.UserInputMessage.Content)
}
}
func TestKiroToClaudeResponseCanEmitEmptyThinkingBlock(t *testing.T) {
resp := KiroToClaudeResponse("final answer", "", true, nil, 10, 20, "claude-sonnet-4.6")
if len(resp.Content) != 2 {
t.Fatalf("expected empty thinking block plus text block, got %d blocks", len(resp.Content))
}
if resp.Content[0].Type != "thinking" {
t.Fatalf("expected first block to be thinking, got %#v", resp.Content[0])
}
if resp.Content[0].Thinking != "" {
t.Fatalf("expected omitted thinking block to have empty content, got %#v", resp.Content[0].Thinking)
}
if resp.Content[1].Type != "text" || resp.Content[1].Text != "final answer" {
t.Fatalf("expected text block to be preserved, got %#v", resp.Content[1])
}
}
func TestToolResultsContinuationIncludesInstructionPrefix(t *testing.T) {
req := &OpenAIRequest{
Model: "claude-sonnet-4.5",
Messages: []OpenAIMessage{
{Role: "user", Content: "find data"},
{Role: "assistant", ToolCalls: []ToolCall{{
ID: "call_1",
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: "fetch", Arguments: "{}"},
}}},
{Role: "tool", ToolCallID: "call_1", Content: "result-1"},
},
}
payload := OpenAIToKiro(req, false)
content := payload.ConversationState.CurrentMessage.UserInputMessage.Content
if !strings.Contains(content, toolResultsContinuationPrefix) {
t.Fatalf("expected tool continuation prefix, got %q", content)
}
if !strings.Contains(content, "result-1") {
t.Fatalf("expected tool result text in continuation content, got %q", content)
}
}
func TestParseModelAndThinkingNoSilentDowngrade(t *testing.T) {
cases := []struct {
input string
want string
thinking bool
}{
// 4.7 family must not silently fall back to 4
{"claude-sonnet-4.7", "claude-sonnet-4.7", false},
{"claude-sonnet-4-7", "claude-sonnet-4.7", false},
{"claude-sonnet-4.7-thinking", "claude-sonnet-4.7", true},
{"claude-opus-4.7", "claude-opus-4.7", false},
{"claude-opus-4-7", "claude-opus-4.7", false},
{"claude-opus-4.7-thinking", "claude-opus-4.7", true},
{"claude-haiku-4.7", "claude-haiku-4.7", false},
{"claude-haiku-4-7", "claude-haiku-4.7", false},
// Existing canonical names still work
{"claude-sonnet-4", "claude-sonnet-4", false},
{"claude-sonnet-4.5", "claude-sonnet-4.5", false},
{"claude-sonnet-4.5-thinking", "claude-sonnet-4.5", true},
{"claude-sonnet-4-20250514", "claude-sonnet-4", false},
// Bedrock-style still maps via Contains on dated keys
{"anthropic.claude-sonnet-4-5-20250929-v1:0", "claude-sonnet-4.5", false},
// Hypothetical future 4.8 must not silently downgrade to 4
{"claude-sonnet-4.8", "claude-sonnet-4.8", false},
}
for _, tc := range cases {
got, gotThinking := ParseModelAndThinking(tc.input, "-thinking")
if got != tc.want || gotThinking != tc.thinking {
t.Errorf("ParseModelAndThinking(%q) = (%q, %v); want (%q, %v)",
tc.input, got, gotThinking, tc.want, tc.thinking)
}
}
}
func TestSanitizeTextStripsInjectionBlocks(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "strips system prompt block",
input: "--- SYSTEM PROMPT ---\nYou are Kiro.\n--- END SYSTEM PROMPT ---\n\nHello",
want: "Hello",
},
{
name: "strips system reminder block",
input: "<system-reminder>\nsome metadata\n</system-reminder>\n\nActual question",
want: "Actual question",
},
{
name: "strips both blocks",
input: "--- SYSTEM PROMPT ---\nidentity\n--- END SYSTEM PROMPT ---\n<system-reminder>ctx</system-reminder>\nReal content",
want: "Real content",
},
{
name: "passes through clean text unchanged",
input: "Just a normal message",
want: "Just a normal message",
},
{
name: "handles multiple dashes",
input: "---- SYSTEM PROMPT ----\nfoo\n---- END SYSTEM PROMPT ----\nbar",
want: "bar",
},
}
for _, tc := range cases {
got := sanitizeText(tc.input)
if got != tc.want {
t.Errorf("sanitizeText(%q) [%s] = %q; want %q", tc.input, tc.name, got, tc.want)
}
}
}
func TestClaudeToKiroSanitizesHistoryContent(t *testing.T) {
injectedAssistant := "--- SYSTEM PROMPT ---\nYou are Kiro.\n--- END SYSTEM PROMPT ---\n\nHere is my answer."
req := &ClaudeRequest{
Model: "claude-sonnet-4.5",
Messages: []ClaudeMessage{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: injectedAssistant},
{Role: "user", Content: "continue"},
},
}
payload := ClaudeToKiro(req, false)
if len(payload.ConversationState.History) < 2 {
t.Fatalf("expected at least 2 history entries, got %d", len(payload.ConversationState.History))
}
assistantEntry := payload.ConversationState.History[1].AssistantResponseMessage
if assistantEntry == nil {
t.Fatalf("expected second history entry to be assistant message")
}
if strings.Contains(assistantEntry.Content, "SYSTEM PROMPT") {
t.Errorf("assistant history content still contains injection block: %q", assistantEntry.Content)
}
if !strings.Contains(assistantEntry.Content, "Here is my answer") {
t.Errorf("assistant history content missing expected text: %q", assistantEntry.Content)
}
}