From eaf121896546bde5d759472ba9ea69f227ca4269 Mon Sep 17 00:00:00 2001 From: asuan-dev <116167305+asuan-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:16:23 +0800 Subject: [PATCH 1/6] fix(protocol): align default Kiro IDE fingerprint to 1.0.212 Default User-Agent / x-amz-user-agent still advertised 0.11.107. Bump the fallback to the current desktop build and keep the per-endpoint header builders explicit so OAuth and API Key paths stay distinguishable. --- config/config.go | 6 +++- proxy/kiro_headers.go | 38 +++++++++++++++++++--- proxy/kiro_headers_test.go | 66 +++++++++++++++++++++++++++++++------- 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/config/config.go b/config/config.go index be31fba1..1fbda693 100644 --- a/config/config.go +++ b/config/config.go @@ -270,7 +270,9 @@ var ( // Init initializes the configuration system with the specified file path. // If the file doesn't exist, a default configuration is created. func Init(path string) error { + cfgLock.Lock() cfgPath = path + cfgLock.Unlock() return Load() } @@ -1206,7 +1208,9 @@ func GetKiroClientConfig() KiroClientConfig { cfgLock.RLock() defer cfgLock.RUnlock() - kiroVersion := "0.11.107" + // OAuth/SSO requests retain the author's IDE compatibility fingerprint; + // advertise the current official desktop version by default. + kiroVersion := "1.0.212" if cfg != nil && cfg.KiroVersion != "" { kiroVersion = cfg.KiroVersion } diff --git a/proxy/kiro_headers.go b/proxy/kiro_headers.go index e704b3eb..13ab0945 100644 --- a/proxy/kiro_headers.go +++ b/proxy/kiro_headers.go @@ -8,8 +8,10 @@ import ( ) const ( - kiroStreamingSDKVersion = "1.0.34" - kiroRuntimeSDKVersion = "1.0.0" + kiroLegacyStreamingSDKVersion = "1.0.39" + kiroRuntimeSDKVersion = "1.0.0" + kiroCLIVersion = "2.14.2" + kiroCLIServiceClientVersion = "0.1.17975" ) type kiroHeaderValues struct { @@ -18,14 +20,36 @@ type kiroHeaderValues struct { Host string } -func buildStreamingHeaderValues(account *config.Account, host string) kiroHeaderValues { - return buildKiroHeaderValues(account, host, "codewhispererstreaming", kiroStreamingSDKVersion, "m/E") +func buildKiroRuntimeHeaderValues(account *config.Account, host string) kiroHeaderValues { + return buildKiroHeaderValues(account, host, "kiroruntime", kiroRuntimeSDKVersion, "m/E") +} + +func buildLegacyStreamingHeaderValues(account *config.Account, host string) kiroHeaderValues { + return buildKiroHeaderValues(account, host, "codewhispererstreaming", kiroLegacyStreamingSDKVersion, "m/E") } func buildRuntimeHeaderValues(account *config.Account, host string) kiroHeaderValues { + if config.IsAPIKeyAccount(account) { + return buildKiroCLIHeaderValues(host, "codewhispererruntime") + } return buildKiroHeaderValues(account, host, "codewhispererruntime", kiroRuntimeSDKVersion, "m/N,E") } +func buildKiroCLIHeaderValues(host, apiName string) kiroHeaderValues { + userAgent := fmt.Sprintf( + "KiroCLI/%s md/appVersion-%s app/AmazonQ-For-CLI api/%s#%s", + kiroCLIVersion, + kiroCLIVersion, + apiName, + kiroCLIServiceClientVersion, + ) + return kiroHeaderValues{ + UserAgent: userAgent, + AmzUserAgent: userAgent, + Host: host, + } +} + func buildKiroHeaderValues(account *config.Account, host, apiName, sdkVersion, mode string) kiroHeaderValues { clientCfg := config.GetKiroClientConfig() machineID := "" @@ -76,7 +100,11 @@ func applyKiroBaseHeaders(req *http.Request, account *config.Account, values kir } req.Header.Set("User-Agent", values.UserAgent) req.Header.Set("x-amz-user-agent", values.AmzUserAgent) - req.Header.Set("x-amzn-codewhisperer-optout", "true") + if account != nil && config.IsAPIKeyAccount(account) { + req.Header.Set("x-amzn-codewhisperer-optout", "false") + } else { + req.Header.Set("x-amzn-codewhisperer-optout", "true") + } if values.Host != "" { req.Host = values.Host } diff --git a/proxy/kiro_headers_test.go b/proxy/kiro_headers_test.go index 74590265..ca1eff63 100644 --- a/proxy/kiro_headers_test.go +++ b/proxy/kiro_headers_test.go @@ -7,24 +7,63 @@ import ( "testing" ) -func TestBuildStreamingHeaderValuesAlignsWithKiroIDEFormat(t *testing.T) { +func TestBuildStreamingHeaderValuesAlignsWithKiroRuntimeIDE(t *testing.T) { account := &config.Account{MachineId: "machine-123"} - values := buildStreamingHeaderValues(account, "q.us-east-1.amazonaws.com") + values := buildKiroRuntimeHeaderValues(account, "runtime.us-east-1.kiro.dev") - if values.Host != "q.us-east-1.amazonaws.com" { + if values.Host != "runtime.us-east-1.kiro.dev" { t.Fatalf("expected host to be preserved, got %q", values.Host) } - if !strings.Contains(values.UserAgent, "aws-sdk-js/1.0.34") { - t.Fatalf("expected streaming sdk version in user agent, got %q", values.UserAgent) + for _, want := range []string{ + "aws-sdk-js/1.0.0", + "api/kiroruntime#1.0.0", + "KiroIDE-1.0.212-machine-123", + } { + if !strings.Contains(values.UserAgent, want) { + t.Fatalf("official KiroRuntime user agent %q missing %q", values.UserAgent, want) + } } - if !strings.Contains(values.UserAgent, "api/codewhispererstreaming#1.0.34") { - t.Fatalf("expected streaming API marker in user agent, got %q", values.UserAgent) + if !strings.Contains(values.AmzUserAgent, "aws-sdk-js/1.0.0 KiroIDE-1.0.212-machine-123") { + t.Fatalf("x-amz-user-agent does not match KiroRuntime IDE format: %q", values.AmzUserAgent) } - if !strings.Contains(values.UserAgent, "KiroIDE-0.11.107-machine-123") { - t.Fatalf("expected kiro version and machine id in user agent, got %q", values.UserAgent) +} + +func TestBuildLegacyStreamingHeaderValuesRetainsEndpointSDK(t *testing.T) { + account := &config.Account{MachineId: "machine-123"} + values := buildLegacyStreamingHeaderValues(account, "q.us-east-1.amazonaws.com") + + for _, want := range []string{ + "aws-sdk-js/1.0.39", + "api/codewhispererstreaming#1.0.39", + "KiroIDE-1.0.212-machine-123", + } { + if !strings.Contains(values.UserAgent, want) { + t.Fatalf("legacy fallback user agent %q missing %q", values.UserAgent, want) + } + } +} + +func TestResolveKiroCLIEndpointUsesCLIIdentity(t *testing.T) { + account := &config.Account{AuthMethod: "api_key", KiroApiKey: "ksk_test", Region: "eu-central-1"} + resolved, err := resolveKiroEndpoint(kiroCLIEndpoint, account, "") + if err != nil { + t.Fatal(err) } - if !strings.Contains(values.AmzUserAgent, "aws-sdk-js/1.0.34 KiroIDE-0.11.107-machine-123") { - t.Fatalf("expected x-amz-user-agent to include version and machine id, got %q", values.AmzUserAgent) + if resolved.URL != "https://runtime.eu-central-1.kiro.dev/" || resolved.ContentType != "application/x-amz-json-1.0" || resolved.AgentMode { + t.Fatalf("resolved CLI endpoint = %+v", resolved) + } + for _, want := range []string{ + "KiroCLI/2.14.2", + "md/appVersion-2.14.2", + "app/AmazonQ-For-CLI", + "api/codewhispererstreaming#0.1.17975", + } { + if !strings.Contains(resolved.HeaderValues.UserAgent, want) { + t.Fatalf("CLI user agent %q missing %q", resolved.HeaderValues.UserAgent, want) + } + } + if strings.Contains(resolved.HeaderValues.UserAgent, "KiroIDE") || strings.Contains(resolved.HeaderValues.UserAgent, "aws-sdk-js") { + t.Fatalf("API key path must not mix IDE/JS identity into CLI UA: %q", resolved.HeaderValues.UserAgent) } } @@ -89,7 +128,7 @@ func TestApplyKiroBaseHeadersMarksAPIKeyCredentials(t *testing.T) { AuthMethod: "api_key", } - applyKiroBaseHeaders(req, account, buildStreamingHeaderValues(account, req.URL.Host)) + applyKiroBaseHeaders(req, account, buildKiroCLIHeaderValues(req.URL.Host, "codewhispererstreaming")) if got := req.Header.Get("Authorization"); got != "Bearer ksk_test_key" { t.Fatalf("expected API key bearer, got %q", got) @@ -101,4 +140,7 @@ func TestApplyKiroBaseHeadersMarksAPIKeyCredentials(t *testing.T) { if got := req.Header.Get("TokenType"); got != "API_KEY" { t.Fatalf("expected TokenType/tokentype API_KEY, got %q", got) } + if got := req.Header.Get("x-amzn-codewhisperer-optout"); got != "false" { + t.Fatalf("expected CLI optout=false, got %q", got) + } } From ae6d9ea9126747bb69e851873cfded26a1502c35 Mon Sep 17 00:00:00 2001 From: asuan-dev <116167305+asuan-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:16:24 +0800 Subject: [PATCH 2/6] fix(stream): CRC-check event frames and buffer parallel tools Parse AWS event-stream messages with prelude/message CRC validation, keep official optional events, and accumulate parallel tool_use frames by id. Flush only after the full tool set validates so a malformed sibling cannot leak a partial tool call to the client. --- proxy/eventstream.go | 183 +++++++++++++ proxy/kiro.go | 512 ++++++++++++++++++++++------------- proxy/kiro_test.go | 197 ++++++++++++-- proxy/stream_drop_test.go | 271 ++++++++++++++++++ proxy/tool_use_order_test.go | 33 +++ 5 files changed, 976 insertions(+), 220 deletions(-) create mode 100644 proxy/eventstream.go create mode 100644 proxy/stream_drop_test.go create mode 100644 proxy/tool_use_order_test.go diff --git a/proxy/eventstream.go b/proxy/eventstream.go new file mode 100644 index 00000000..1459c9da --- /dev/null +++ b/proxy/eventstream.go @@ -0,0 +1,183 @@ +package proxy + +import ( + "encoding/binary" + "fmt" + "hash/crc32" + "io" + "strings" +) + +const ( + eventStreamPreludeLength = 12 + eventStreamMessageCRCLength = 4 + eventStreamMaxHeadersLength = 128 * 1024 + eventStreamMaxPayloadLength = 16 * 1024 * 1024 +) + +type eventStreamMessage struct { + headers map[string]string + payload []byte +} + +type upstreamEventStreamError struct { + messageType string + code string + message string + reason string + retryAfterMilliseconds string +} + +func (e *upstreamEventStreamError) Error() string { + parts := []string{"upstream event stream " + e.messageType} + if e.code != "" { + parts = append(parts, e.code) + } + if e.message != "" { + parts = append(parts, e.message) + } + if e.reason != "" { + parts = append(parts, "reason="+e.reason) + } + if e.retryAfterMilliseconds != "" { + parts = append(parts, "retryAfterMilliseconds="+e.retryAfterMilliseconds) + } + return strings.Join(parts, ": ") +} + +func newUpstreamEventStreamError(messageType, code, headerMessage string, payload map[string]interface{}) error { + message := strings.TrimSpace(headerMessage) + if message == "" { + message = firstStringField(payload, "message", "errorMessage", "error") + } + + retryAfter := "" + if value, ok := payload["retryAfterMilliseconds"]; ok && value != nil { + retryAfter = fmt.Sprint(value) + } + + return &upstreamEventStreamError{ + messageType: messageType, + code: strings.TrimSpace(code), + message: message, + reason: firstStringField(payload, "reason"), + retryAfterMilliseconds: retryAfter, + } +} + +// readEventStreamMessage validates and decodes one AWS EventStream envelope. +// A nil message and nil error means a clean EOF between messages. +func readEventStreamMessage(body io.Reader) (*eventStreamMessage, error) { + prelude := make([]byte, eventStreamPreludeLength) + if _, err := io.ReadFull(body, prelude); err != nil { + if err == io.EOF { + return nil, nil + } + return nil, fmt.Errorf("read event stream prelude: %w", err) + } + + wantPreludeCRC := binary.BigEndian.Uint32(prelude[8:12]) + gotPreludeCRC := crc32.ChecksumIEEE(prelude[:8]) + if gotPreludeCRC != wantPreludeCRC { + return nil, fmt.Errorf("event stream prelude CRC mismatch: got %08x, want %08x", gotPreludeCRC, wantPreludeCRC) + } + + totalLength := binary.BigEndian.Uint32(prelude[0:4]) + headersLength := binary.BigEndian.Uint32(prelude[4:8]) + if totalLength < eventStreamPreludeLength+eventStreamMessageCRCLength { + return nil, fmt.Errorf("invalid event stream total length %d", totalLength) + } + if headersLength > eventStreamMaxHeadersLength { + return nil, fmt.Errorf("event stream headers length %d exceeds limit %d", headersLength, eventStreamMaxHeadersLength) + } + if uint64(headersLength)+eventStreamPreludeLength+eventStreamMessageCRCLength > uint64(totalLength) { + return nil, fmt.Errorf("invalid event stream headers length %d for total length %d", headersLength, totalLength) + } + payloadLength := totalLength - headersLength - eventStreamPreludeLength - eventStreamMessageCRCLength + if payloadLength > eventStreamMaxPayloadLength { + return nil, fmt.Errorf("event stream payload length %d exceeds limit %d", payloadLength, eventStreamMaxPayloadLength) + } + + remaining := make([]byte, int(totalLength)-eventStreamPreludeLength) + if _, err := io.ReadFull(body, remaining); err != nil { + return nil, fmt.Errorf("read event stream message: %w", err) + } + + messageCRCOffset := len(remaining) - eventStreamMessageCRCLength + wantMessageCRC := binary.BigEndian.Uint32(remaining[messageCRCOffset:]) + checksum := crc32.NewIEEE() + _, _ = checksum.Write(prelude) + _, _ = checksum.Write(remaining[:messageCRCOffset]) + gotMessageCRC := checksum.Sum32() + if gotMessageCRC != wantMessageCRC { + return nil, fmt.Errorf("event stream message CRC mismatch: got %08x, want %08x", gotMessageCRC, wantMessageCRC) + } + + headersEnd := int(headersLength) + headers, err := parseEventStreamHeaders(remaining[:headersEnd]) + if err != nil { + return nil, err + } + payload := remaining[headersEnd:messageCRCOffset] + return &eventStreamMessage{headers: headers, payload: payload}, nil +} + +func parseEventStreamHeaders(encoded []byte) (map[string]string, error) { + headers := make(map[string]string) + for offset := 0; offset < len(encoded); { + nameLength := int(encoded[offset]) + offset++ + if nameLength == 0 || offset+nameLength+1 > len(encoded) { + return nil, fmt.Errorf("malformed event stream header name at offset %d", offset-1) + } + name := string(encoded[offset : offset+nameLength]) + offset += nameLength + valueType := encoded[offset] + offset++ + + switch valueType { + case 0, 1: // boolean true / false: no value bytes + case 2: + if offset+1 > len(encoded) { + return nil, fmt.Errorf("malformed byte header %q", name) + } + offset++ + case 3: + if offset+2 > len(encoded) { + return nil, fmt.Errorf("malformed int16 header %q", name) + } + offset += 2 + case 4: + if offset+4 > len(encoded) { + return nil, fmt.Errorf("malformed int32 header %q", name) + } + offset += 4 + case 5, 8: + if offset+8 > len(encoded) { + return nil, fmt.Errorf("malformed int64 header %q", name) + } + offset += 8 + case 6, 7: + if offset+2 > len(encoded) { + return nil, fmt.Errorf("malformed variable-length header %q", name) + } + valueLength := int(binary.BigEndian.Uint16(encoded[offset : offset+2])) + offset += 2 + if offset+valueLength > len(encoded) { + return nil, fmt.Errorf("malformed variable-length header %q", name) + } + if valueType == 7 { + headers[name] = string(encoded[offset : offset+valueLength]) + } + offset += valueLength + case 9: + if offset+16 > len(encoded) { + return nil, fmt.Errorf("malformed UUID header %q", name) + } + offset += 16 + default: + return nil, fmt.Errorf("unsupported event stream header type %d for %q", valueType, name) + } + } + return headers, nil +} diff --git a/proxy/kiro.go b/proxy/kiro.go index 1786ba0f..755758c3 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -22,19 +22,35 @@ import ( ) // Endpoint configuration (auto-fallback on quota exhaustion). +type kiroEndpointProtocol uint8 + +const ( + kiroProtocolLegacyStreaming kiroEndpointProtocol = iota + kiroProtocolRuntime + kiroProtocolCLI +) + type kiroEndpoint struct { URL string Origin string AmzTarget string Name string + Protocol kiroEndpointProtocol +} + +type resolvedKiroEndpoint struct { + URL string + HeaderValues kiroHeaderValues + ContentType string + AgentMode bool } var kiroEndpoints = []kiroEndpoint{ { - URL: "https://q.us-east-1.amazonaws.com/generateAssistantResponse", - Origin: "AI_EDITOR", - AmzTarget: "", - Name: "Kiro IDE", + URL: "https://runtime.us-east-1.kiro.dev/generateAssistantResponse", + Origin: "AI_EDITOR", + Name: "Kiro Runtime", + Protocol: kiroProtocolRuntime, }, { URL: "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse", @@ -57,6 +73,7 @@ var kiroCLIEndpoint = kiroEndpoint{ Origin: "KIRO_CLI", AmzTarget: "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", Name: "Kiro CLI", + Protocol: kiroProtocolCLI, } // Global HTTP clients, swappable at runtime to apply proxy reconfiguration without restart. @@ -179,6 +196,7 @@ type KiroUserInputMessage struct { ModelID string `json:"modelId,omitempty"` Origin string `json:"origin"` Images []KiroImage `json:"images,omitempty"` + Documents []KiroDocument `json:"documents,omitempty"` UserInputMessageContext *UserInputMessageContext `json:"userInputMessageContext,omitempty"` } @@ -216,14 +234,34 @@ type KiroImage struct { } `json:"source"` } +// KiroDocument is a non-image attachment (PDF, office, text, ...). +type KiroDocument struct { + Name string `json:"name"` + Format string `json:"format"` + Source struct { + Bytes string `json:"bytes"` + } `json:"source"` +} + +// KiroReasoningContent is the signed thinking stamp upstream expects on later +// turns. Either open text+signature or redacted content. +type KiroReasoningContent struct { + ReasoningText *struct { + Text string `json:"text"` + Signature string `json:"signature"` + } `json:"reasoningText,omitempty"` + RedactedContent []byte `json:"redactedContent,omitempty"` +} + type KiroHistoryMessage struct { UserInputMessage *KiroUserInputMessage `json:"userInputMessage,omitempty"` AssistantResponseMessage *KiroAssistantResponseMessage `json:"assistantResponseMessage,omitempty"` } type KiroAssistantResponseMessage struct { - Content string `json:"content"` - ToolUses []KiroToolUse `json:"toolUses,omitempty"` + Content string `json:"content"` + ToolUses []KiroToolUse `json:"toolUses,omitempty"` + ReasoningContent *KiroReasoningContent `json:"reasoningContent,omitempty"` } type KiroToolUse struct { @@ -248,6 +286,13 @@ type KiroStreamCallback struct { OnError func(err error) OnCredits func(credits float64) OnContextUsage func(percentage float64) + // OnStopReason is fired when a metadataEvent carries stopReason. + // Absence after content is how callers detect a truncated stream + // (Kiro IDE retries those; see _streamResponseChunks). + OnStopReason func(reason string) + // OnReasoningMeta carries only the validation stamp for a thinking + // segment. Visible text still arrives via OnText(..., true). + OnReasoningMeta func(signature, redactedBase64 string) } // ==================== API Call ==================== @@ -281,15 +326,33 @@ func endpointsForAccount(account *config.Account) []kiroEndpoint { return getSortedEndpoints(config.GetPreferredEndpoint()) } -// cliRuntimeURL builds the regional Kiro CLI runtime URL. -func cliRuntimeURL(account *config.Account) string { - region := "us-east-1" - if account != nil { - if r := strings.TrimSpace(account.Region); r != "" { - region = r - } +func resolveKiroEndpoint(endpoint kiroEndpoint, account *config.Account, profileArn string) (resolvedKiroEndpoint, error) { + region := kiroRegionForProfile(account, profileArn) + resolvedURL := endpoint.URL + if endpoint.Protocol == kiroProtocolRuntime || endpoint.Protocol == kiroProtocolCLI { + resolvedURL = strings.Replace(endpoint.URL, "runtime.us-east-1.kiro.dev", "runtime."+region+".kiro.dev", 1) + } else if endpoint.Protocol == kiroProtocolLegacyStreaming { + resolvedURL = regionalizeURLForProfile(endpoint.URL, account, profileArn) + } else { + return resolvedKiroEndpoint{}, fmt.Errorf("unsupported Kiro endpoint protocol %d", endpoint.Protocol) } - return fmt.Sprintf("https://runtime.%s.kiro.dev/", region) + parsed, err := url.Parse(resolvedURL) + if err != nil || parsed.Host == "" { + return resolvedKiroEndpoint{}, fmt.Errorf("invalid Kiro endpoint URL %q", resolvedURL) + } + + resolved := resolvedKiroEndpoint{URL: resolvedURL, ContentType: "application/json", AgentMode: true} + switch endpoint.Protocol { + case kiroProtocolRuntime: + resolved.HeaderValues = buildKiroRuntimeHeaderValues(account, parsed.Host) + case kiroProtocolCLI: + resolved.HeaderValues = buildKiroCLIHeaderValues(parsed.Host, "codewhispererstreaming") + resolved.ContentType = "application/x-amz-json-1.0" + resolved.AgentMode = false + case kiroProtocolLegacyStreaming: + resolved.HeaderValues = buildLegacyStreamingHeaderValues(account, parsed.Host) + } + return resolved, nil } // getSortedEndpoints returns endpoints ordered by user preference, with optional fallback. @@ -370,52 +433,33 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt // Build endpoint list ordered by configuration / credential type. endpoints := endpointsForAccount(account) - isAPIKey := config.IsAPIKeyAccount(account) var lastErr error for _, ep := range endpoints { // Update the origin field for the selected endpoint. payload.ConversationState.CurrentMessage.UserInputMessage.Origin = ep.Origin - // Target the profile's data-plane region; endpoint URLs are declared for us-east-1. - // API Key accounts use the CLI runtime host instead of IDE/Q hosts. - epURL := regionalizeURLForProfile(ep.URL, account, payload.ProfileArn) - if isAPIKey { - epURL = cliRuntimeURL(account) + resolved, err := resolveKiroEndpoint(ep, account, payload.ProfileArn) + if err != nil { + lastErr = err + continue } reqBody, _ := json.Marshal(payload) - req, err := http.NewRequest("POST", epURL, bytes.NewReader(reqBody)) + req, err := http.NewRequest("POST", resolved.URL, bytes.NewReader(reqBody)) if err != nil { lastErr = err continue } - - host := "" - if parsedURL, parseErr := url.Parse(epURL); parseErr == nil { - host = parsedURL.Host - } - headerValues := buildStreamingHeaderValues(account, host) - - if isAPIKey { - req.Header.Set("Content-Type", "application/x-amz-json-1.0") - } else { - req.Header.Set("Content-Type", "application/json") - } + req.Header.Set("Content-Type", resolved.ContentType) req.Header.Set("Accept", "*/*") if ep.AmzTarget != "" { req.Header.Set("X-Amz-Target", ep.AmzTarget) } - applyKiroBaseHeaders(req, account, headerValues) - if !isAPIKey { + applyKiroBaseHeaders(req, account, resolved.HeaderValues) + if resolved.AgentMode { req.Header.Set("x-amzn-kiro-agent-mode", "vibe") } - // CLI captures use optout=false; IDE path keeps true. - if isAPIKey { - req.Header.Set("x-amzn-codewhisperer-optout", "false") - } else { - req.Header.Set("x-amzn-codewhisperer-optout", "true") - } req.Header.Set("Amz-Sdk-Request", "attempt=1; max=3") req.Header.Set("Amz-Sdk-Invocation-Id", uuid.New().String()) @@ -471,105 +515,116 @@ func parseEventStream(body io.Reader, callback *KiroStreamCallback) error { callback = &KiroStreamCallback{} } - // Read directly without bufio to avoid buffering latency in streaming responses. var inputTokens, outputTokens int var totalCredits float64 - var currentToolUse *toolUseState + pending := &pendingToolUses{} for { - // Prelude: 12 bytes (total_len + headers_len + crc) - prelude := make([]byte, 12) - _, err := io.ReadFull(body, prelude) - if err == io.EOF { - break - } + message, err := readEventStreamMessage(body) if err != nil { return err } - - totalLength := int(prelude[0])<<24 | int(prelude[1])<<16 | int(prelude[2])<<8 | int(prelude[3]) - headersLength := int(prelude[4])<<24 | int(prelude[5])<<16 | int(prelude[6])<<8 | int(prelude[7]) - - if totalLength < 16 { - continue + if message == nil { + break } - // Read the remaining message bytes. - remaining := totalLength - 12 - msgBuf := make([]byte, remaining) - _, err = io.ReadFull(body, msgBuf) - if err != nil { - return err + messageType := strings.TrimSpace(message.headers[":message-type"]) + if messageType == "" { + return fmt.Errorf("event stream message missing :message-type header") } - if headersLength > len(msgBuf)-4 { - continue + if messageType == "error" || messageType == "exception" { + var event map[string]interface{} + headerMessage := message.headers[":error-message"] + if len(message.payload) > 0 && string(message.payload) != "null" { + if err := json.Unmarshal(message.payload, &event); err != nil && headerMessage == "" { + headerMessage = strings.TrimSpace(string(message.payload)) + } + } + if event == nil { + event = map[string]interface{}{} + } + code := message.headers[":error-code"] + if messageType == "exception" { + code = message.headers[":exception-type"] + } + return newUpstreamEventStreamError(messageType, code, headerMessage, event) + } + if messageType != "event" { + return fmt.Errorf("unsupported event stream message type %q", messageType) } - eventType := extractEventType(msgBuf[0:headersLength]) - payloadBytes := msgBuf[headersLength : len(msgBuf)-4] - if len(payloadBytes) == 0 { - continue + eventType := strings.TrimSpace(message.headers[":event-type"]) + if eventType == "" { + return fmt.Errorf("event stream event missing :event-type header") } var event map[string]interface{} - if err := json.Unmarshal(payloadBytes, &event); err != nil { - continue + if len(message.payload) > 0 { + if err := json.Unmarshal(message.payload, &event); err != nil { + return fmt.Errorf("decode event stream %s payload: %w", eventType, err) + } + } + if event == nil { + event = map[string]interface{}{} } inputTokens, outputTokens = updateTokensFromEvent(event, inputTokens, outputTokens) - // Dispatch by event type. switch eventType { - // Both text streams are passed through verbatim. Kiro sends - // assistantResponseEvent and reasoningContentEvent as pure incremental deltas - // (verified against real upstream traffic), never as cumulative snapshots, and - // never replays a chunk: ordering and at-most-once delivery are already - // guaranteed by TCP, and a dropped stream is retried as a whole new request - // rather than resumed. - // - // Do NOT reintroduce content-based de-duplication here. At the string level a - // replayed chunk is indistinguishable from text that simply repeats itself, and - // the wire protocol carries no sequence number or message id to tell them apart - // (the AWS event-stream base spec defines none), so such a heuristic can only - // guess -- and when it guesses wrong it silently eats real output. The previous - // implementation turned "6666666666" into "666", "abababab" into "abab" and - // "1833" into "183", on both streams. case "assistantResponseEvent": - if content, ok := event["content"].(string); ok && content != "" { - if callback.OnText != nil { - callback.OnText(content, false) - } + if content, ok := event["content"].(string); ok && content != "" && callback.OnText != nil { + callback.OnText(content, false) } case "reasoningContentEvent": - if text, ok := event["text"].(string); ok && text != "" { - if callback.OnText != nil { - callback.OnText(text, true) + if text, ok := event["text"].(string); ok && text != "" && callback.OnText != nil { + callback.OnText(text, true) + } + if callback.OnReasoningMeta != nil { + sig, _ := event["signature"].(string) + redacted, _ := event["redactedContent"].(string) + if sig != "" || redacted != "" { + callback.OnReasoningMeta(sig, redacted) } } case "toolUseEvent": - currentToolUse = handleToolUseEvent(event, currentToolUse, callback) + if err := handleToolUseEvent(event, pending); err != nil { + return err + } case "meteringEvent": if usage, ok := event["usage"].(float64); ok { totalCredits += usage } case "contextUsageEvent": - if pct, ok := event["contextUsagePercentage"].(float64); ok { - if callback.OnContextUsage != nil { - callback.OnContextUsage(pct) - } + if pct, ok := event["contextUsagePercentage"].(float64); ok && callback.OnContextUsage != nil { + callback.OnContextUsage(pct) } - } - } - - if currentToolUse != nil { - finishToolUse(currentToolUse, callback) + case "metadataEvent": + if reason := firstStringField(event, "stopReason", "stop_reason"); reason != "" && callback.OnStopReason != nil { + callback.OnStopReason(reason) + } + case "error", "throttlingError", "validationError", "serviceUnavailableError", "invalidStateEvent": + return newUpstreamEventStreamError("event", eventType, "", event) + case "codeReferenceEvent", "documentCitationEvent", "toolResultEvent", + "supplementaryWebLinksEvent", "messageMetadataEvent", "interactionComponentsEvent", + "intentsEvent", "followupPromptEvent", "citationEvent", "codeEvent", "dryRunSucceedEvent": + // These official metadata/presentation events have no portable + // representation in the OpenAI/Anthropic compatibility APIs. + logger.Debugf("[EventStream] ignoring known optional event %s", eventType) + default: + return fmt.Errorf("unsupported upstream event stream event %q", eventType) + } + } + + // Flush tools that omitted an explicit stop frame. Any invalid argument + // buffer makes the entire parallel tool turn fail. + if err := pending.flushAll(callback); err != nil { + return err } if callback.OnCredits != nil && totalCredits > 0 { callback.OnCredits(totalCredits) } - if callback.OnComplete != nil { callback.OnComplete(inputTokens, outputTokens) } @@ -701,7 +756,6 @@ func collectUsageMaps(v interface{}, out *[]map[string]interface{}) { } } - func readTokenNumber(m map[string]interface{}, keys ...string) (int, bool) { for _, k := range keys { v, ok := m[k] @@ -737,70 +791,198 @@ type toolUseState struct { ToolUseID string Name string InputBuffer strings.Builder + SawInput bool GeneratedID bool } -func handleToolUseEvent(event map[string]interface{}, current *toolUseState, callback *KiroStreamCallback) *toolUseState { - toolUseID := firstStringField(event, "toolUseId", "toolUseID", "tool_use_id", "id") - name := firstStringField(event, "name", "toolName", "tool_name") - isStop := firstBoolField(event, "stop", "isStop", "done") +// pendingToolUses tracks in-flight tool calls for one stream. Entries are keyed +// by toolUseId so interleaved parallel frames accumulate independently, and +// `order` preserves arrival sequence: tool call order is semantic for clients, +// so a bare map range (randomised in Go) must never decide emit order. +type pendingToolUses struct { + byID map[string]*toolUseState + completed map[string]KiroToolUse + order []string + lastID string +} + +func (p *pendingToolUses) get(id string) *toolUseState { + if p.byID == nil { + return nil + } + return p.byID[id] +} - if toolUseID != "" && name != "" { - if current == nil { - current = &toolUseState{ToolUseID: toolUseID, Name: name} - } else if current.ToolUseID != toolUseID { - if current.GeneratedID && current.Name == name { - current.ToolUseID = toolUseID - current.GeneratedID = false - } else { - finishToolUse(current, callback) - current = &toolUseState{ToolUseID: toolUseID, Name: name} +func (p *pendingToolUses) add(state *toolUseState) { + if p.byID == nil { + p.byID = map[string]*toolUseState{} + } + p.byID[state.ToolUseID] = state + p.order = append(p.order, state.ToolUseID) + p.lastID = state.ToolUseID +} + +func (p *pendingToolUses) isCompleted(id string) bool { + _, ok := p.completed[id] + return ok +} + +// rekey moves an entry from a generated id to the real id from upstream, +// keeping its position in the arrival order. +func (p *pendingToolUses) rekey(state *toolUseState, newID string) { + oldID := state.ToolUseID + delete(p.byID, oldID) + for i, id := range p.order { + if id == oldID { + p.order[i] = newID + break + } + } + state.ToolUseID = newID + state.GeneratedID = false + p.byID[newID] = state + if p.lastID == oldID { + p.lastID = newID + } +} + +func (p *pendingToolUses) complete(state *toolUseState) error { + toolUse, err := decodeToolUse(state) + if err != nil { + return err + } + if p.completed == nil { + p.completed = make(map[string]KiroToolUse) + } + p.completed[state.ToolUseID] = toolUse + delete(p.byID, state.ToolUseID) + if p.lastID == state.ToolUseID { + p.lastID = "" + for i := len(p.order) - 1; i >= 0; i-- { + if p.byID[p.order[i]] != nil { + p.lastID = p.order[i] + break } } - } else if name != "" && current == nil { - current = &toolUseState{ToolUseID: "toolu_" + uuid.New().String(), Name: name, GeneratedID: true} - } else if name != "" && current != nil && current.Name != name { - finishToolUse(current, callback) - current = &toolUseState{ToolUseID: "toolu_" + uuid.New().String(), Name: name, GeneratedID: true} } + return nil +} - if current != nil { - if input, ok := event["input"].(string); ok { - current.InputBuffer.WriteString(input) - } else if inputObj, ok := event["input"].(map[string]interface{}); ok { - data, _ := json.Marshal(inputObj) - current.InputBuffer.Reset() - current.InputBuffer.Write(data) +// flushAll validates the complete parallel set before exposing any tool to the +// caller. A malformed sibling therefore cannot leak an otherwise valid tool. +func (p *pendingToolUses) flushAll(callback *KiroStreamCallback) error { + for _, id := range p.order { + if p.isCompleted(id) { + continue + } + if state := p.byID[id]; state != nil { + if err := p.complete(state); err != nil { + return err + } + } + } + if callback != nil && callback.OnToolUse != nil { + for _, id := range p.order { + if toolUse, ok := p.completed[id]; ok { + callback.OnToolUse(toolUse) + } } } + p.byID = nil + p.completed = nil + p.order = nil + p.lastID = "" + return nil +} - if isStop && current != nil { - finishToolUse(current, callback) +// handleToolUseEvent accumulates a toolUseEvent into pending. Frames for +// different IDs remain independent; completed tools stay buffered until EOF. +func handleToolUseEvent(event map[string]interface{}, pending *pendingToolUses) error { + toolUseID := firstStringField(event, "toolUseId", "toolUseID", "tool_use_id", "id") + name := firstStringField(event, "name", "toolName", "tool_name") + isStop := firstBoolField(event, "stop", "isStop", "done") + + var state *toolUseState + switch { + case toolUseID != "": + if pending.isCompleted(toolUseID) { + return nil + } + state = pending.get(toolUseID) + if state == nil && pending.lastID != "" { + if previous := pending.get(pending.lastID); previous != nil && previous.GeneratedID && (name == "" || previous.Name == name) { + pending.rekey(previous, toolUseID) + state = previous + } + } + if state == nil { + if name == "" { + return nil + } + state = &toolUseState{ToolUseID: toolUseID, Name: name} + pending.add(state) + } else { + if name != "" && state.Name == "" { + state.Name = name + } + pending.lastID = state.ToolUseID + } + case pending.lastID != "" && pending.get(pending.lastID) != nil: + state = pending.get(pending.lastID) + if name != "" && state.Name != name { + if err := pending.complete(state); err != nil { + return err + } + state = &toolUseState{ToolUseID: "toolu_" + uuid.New().String(), Name: name, GeneratedID: true} + pending.add(state) + } + case name != "": + state = &toolUseState{ToolUseID: "toolu_" + uuid.New().String(), Name: name, GeneratedID: true} + pending.add(state) + default: return nil } - return current + if input, ok := event["input"].(string); ok { + state.SawInput = true + state.InputBuffer.WriteString(input) + } else if inputObj, ok := event["input"].(map[string]interface{}); ok { + state.SawInput = true + data, _ := json.Marshal(inputObj) + state.InputBuffer.Reset() + state.InputBuffer.Write(data) + } + + if isStop { + return pending.complete(state) + } + return nil } -func finishToolUse(state *toolUseState, callback *KiroStreamCallback) { - if state == nil || state.Name == "" || callback == nil || callback.OnToolUse == nil { - return +func decodeToolUse(state *toolUseState) (KiroToolUse, error) { + if state == nil || state.Name == "" { + return KiroToolUse{}, fmt.Errorf("%w: missing tool identity", errIncompleteToolUse) } if state.ToolUseID == "" { state.ToolUseID = "toolu_" + uuid.New().String() } + if !state.SawInput { + return KiroToolUse{}, fmt.Errorf("%w: %s (%s): missing input", errIncompleteToolUse, state.Name, state.ToolUseID) + } var input map[string]interface{} if state.InputBuffer.Len() > 0 { - json.Unmarshal([]byte(state.InputBuffer.String()), &input) + if err := json.Unmarshal([]byte(state.InputBuffer.String()), &input); err != nil { + return KiroToolUse{}, fmt.Errorf("%w: %s (%s): %v", errIncompleteToolUse, state.Name, state.ToolUseID, err) + } } if input == nil { input = make(map[string]interface{}) } - callback.OnToolUse(KiroToolUse{ + return KiroToolUse{ ToolUseID: state.ToolUseID, Name: state.Name, Input: input, - }) + }, nil } func firstStringField(m map[string]interface{}, keys ...string) string { @@ -820,57 +1002,3 @@ func firstBoolField(m map[string]interface{}, keys ...string) bool { } return false } - -// extractEventType extracts the event type string from AWS Event Stream message headers. -func extractEventType(headers []byte) string { - offset := 0 - for offset < len(headers) { - if offset >= len(headers) { - break - } - nameLen := int(headers[offset]) - offset++ - if offset+nameLen > len(headers) { - break - } - name := string(headers[offset : offset+nameLen]) - offset += nameLen - if offset >= len(headers) { - break - } - valueType := headers[offset] - offset++ - - if valueType == 7 { // String - if offset+2 > len(headers) { - break - } - valueLen := int(headers[offset])<<8 | int(headers[offset+1]) - offset += 2 - if offset+valueLen > len(headers) { - break - } - value := string(headers[offset : offset+valueLen]) - offset += valueLen - if name == ":event-type" { - return value - } - continue - } - - // Skip other value types by their fixed byte widths. - skipSizes := map[byte]int{0: 0, 1: 0, 2: 1, 3: 2, 4: 4, 5: 8, 8: 8, 9: 16} - if valueType == 6 { - if offset+2 > len(headers) { - break - } - l := int(headers[offset])<<8 | int(headers[offset+1]) - offset += 2 + l - } else if skip, ok := skipSizes[valueType]; ok { - offset += skip - } else { - break - } - } - return "" -} diff --git a/proxy/kiro_test.go b/proxy/kiro_test.go index 14af12f5..0fcaaa37 100644 --- a/proxy/kiro_test.go +++ b/proxy/kiro_test.go @@ -4,9 +4,11 @@ import ( "bytes" "encoding/binary" "encoding/json" + "hash/crc32" "kiro-go/config" "net/http" "net/url" + "strings" "testing" "time" ) @@ -139,20 +141,116 @@ func TestParseEventStreamNilCallbackFieldsAreNoOp(t *testing.T) { } } +func TestParseEventStreamRejectsPreludeCRCMismatch(t *testing.T) { + frame := awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "hello"}) + frame[8] ^= 0xff + + err := parseEventStream(bytes.NewReader(frame), &KiroStreamCallback{}) + if err == nil || !strings.Contains(err.Error(), "prelude CRC") { + t.Fatalf("parse error = %v, want prelude CRC mismatch", err) + } +} + +func TestParseEventStreamRejectsMessageCRCMismatchBeforeCallbacks(t *testing.T) { + frame := awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "hello"}) + frame[len(frame)-1] ^= 0xff + + called := false + err := parseEventStream(bytes.NewReader(frame), &KiroStreamCallback{ + OnText: func(string, bool) { called = true }, + }) + if err == nil || !strings.Contains(err.Error(), "message CRC") { + t.Fatalf("parse error = %v, want message CRC mismatch", err) + } + if called { + t.Fatal("corrupt frame reached callback") + } +} + +func TestParseEventStreamReturnsModeledException(t *testing.T) { + frame := awsEventStreamMessage(t, map[string]string{ + ":message-type": "exception", + ":exception-type": "throttlingError", + }, map[string]interface{}{ + "message": "rate limited", + "reason": "INSUFFICIENT_MODEL_CAPACITY", + "retryAfterMilliseconds": 1250, + }) + + err := parseEventStream(bytes.NewReader(frame), &KiroStreamCallback{}) + if err == nil { + t.Fatal("expected modeled stream exception") + } + for _, want := range []string{"throttlingError", "rate limited", "INSUFFICIENT_MODEL_CAPACITY", "1250"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("stream exception %q missing %q", err, want) + } + } +} + +func TestParseEventStreamReturnsUnmodeledError(t *testing.T) { + frame := awsEventStreamMessage(t, map[string]string{ + ":message-type": "error", + ":error-code": "UpstreamUnavailable", + ":error-message": "try later", + }, nil) + + err := parseEventStream(bytes.NewReader(frame), &KiroStreamCallback{}) + if err == nil || !strings.Contains(err.Error(), "UpstreamUnavailable") || !strings.Contains(err.Error(), "try later") { + t.Fatalf("parse error = %v, want upstream error details", err) + } +} + +func TestParseEventStreamRejectsUnknownEvent(t *testing.T) { + frame := awsEventStreamFrame(t, "futureEvent", map[string]interface{}{"value": true}) + + err := parseEventStream(bytes.NewReader(frame), &KiroStreamCallback{}) + if err == nil || !strings.Contains(err.Error(), "futureEvent") { + t.Fatalf("parse error = %v, want unsupported event", err) + } +} + +func TestParseEventStreamIgnoresKnownOptionalEvents(t *testing.T) { + stream := bytes.NewReader(bytes.Join([][]byte{ + awsEventStreamFrame(t, "codeReferenceEvent", map[string]interface{}{"references": []interface{}{}}), + awsEventStreamFrame(t, "documentCitationEvent", map[string]interface{}{"title": "source"}), + awsEventStreamFrame(t, "toolResultEvent", map[string]interface{}{}), + awsEventStreamFrame(t, "supplementaryWebLinksEvent", map[string]interface{}{"links": []interface{}{}}), + awsEventStreamFrame(t, "messageMetadataEvent", map[string]interface{}{}), + awsEventStreamFrame(t, "interactionComponentsEvent", map[string]interface{}{}), + awsEventStreamFrame(t, "intentsEvent", map[string]interface{}{}), + awsEventStreamFrame(t, "followupPromptEvent", map[string]interface{}{}), + awsEventStreamFrame(t, "citationEvent", map[string]interface{}{}), + awsEventStreamFrame(t, "codeEvent", map[string]interface{}{}), + awsEventStreamFrame(t, "dryRunSucceedEvent", map[string]interface{}{}), + }, nil)) + + if err := parseEventStream(stream, &KiroStreamCallback{}); err != nil { + t.Fatalf("known optional events must remain compatible: %v", err) + } +} + func TestHandleToolUseEventGeneratesMissingToolUseID(t *testing.T) { var toolUses []KiroToolUse - current := handleToolUseEvent(map[string]interface{}{ + pending := &pendingToolUses{} + if err := handleToolUseEvent(map[string]interface{}{ "name": "mcpIdaProMcpStatus", "input": `{"server":"ida-pro-mcp"}`, "stop": true, - }, nil, &KiroStreamCallback{ - OnToolUse: func(toolUse KiroToolUse) { - toolUses = append(toolUses, toolUse) - }, - }) + }, pending); err != nil { + t.Fatalf("handle tool event: %v", err) + } + if len(toolUses) != 0 { + t.Fatalf("tool must remain buffered until full-set validation: %+v", toolUses) + } + if err := pending.flushAll(&KiroStreamCallback{OnToolUse: func(toolUse KiroToolUse) { + toolUses = append(toolUses, toolUse) + }}); err != nil { + t.Fatalf("flush tools: %v", err) + } - if current != nil { - t.Fatalf("expected stopped tool use to clear current state") + if len(pending.order) != 0 { + t.Fatalf("expected stopped tool use to clear pending state, got %d", len(pending.order)) } if len(toolUses) != 1 { t.Fatalf("expected one tool use, got %d", len(toolUses)) @@ -172,20 +270,31 @@ func TestHandleToolUseEventReplacesGeneratedIDWhenRealIDArrives(t *testing.T) { toolUses = append(toolUses, toolUse) }, } + pending := &pendingToolUses{} - current := handleToolUseEvent(map[string]interface{}{ + if err := handleToolUseEvent(map[string]interface{}{ "name": "mcpIdaProMcpStatus", "input": `{"server":`, - }, nil, callback) - current = handleToolUseEvent(map[string]interface{}{ + }, pending); err != nil { + t.Fatalf("first tool fragment: %v", err) + } + if err := handleToolUseEvent(map[string]interface{}{ "toolUseId": "toolu_real", "name": "mcpIdaProMcpStatus", "input": `"ida-pro-mcp"}`, "stop": true, - }, current, callback) + }, pending); err != nil { + t.Fatalf("final tool fragment: %v", err) + } + if len(toolUses) != 0 { + t.Fatalf("tool must remain buffered until full-set validation: %+v", toolUses) + } + if err := pending.flushAll(callback); err != nil { + t.Fatalf("flush tools: %v", err) + } - if current != nil { - t.Fatalf("expected stopped tool use to clear current state") + if len(pending.order) != 0 { + t.Fatalf("expected stopped tool use to clear pending state, got %d", len(pending.order)) } if len(toolUses) != 1 { t.Fatalf("expected one completed tool use, got %d", len(toolUses)) @@ -269,6 +378,23 @@ func TestSetPayloadProfileArnForAccountClearsAPIKeyProfile(t *testing.T) { } } +func TestEndpointsForAccountPrefersKiroRuntimeForOAuth(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("init config: %v", err) + } + account := &config.Account{AccessToken: "token", Region: "eu-central-1"} + eps := endpointsForAccount(account) + if len(eps) == 0 { + t.Fatal("expected at least one endpoint") + } + if eps[0].Name != "Kiro Runtime" || eps[0].Origin != "AI_EDITOR" { + t.Fatalf("unexpected primary endpoint: %+v", eps[0]) + } + if resolved, err := resolveKiroEndpoint(eps[0], account, ""); err != nil || resolved.URL != "https://runtime.eu-central-1.kiro.dev/generateAssistantResponse" { + t.Fatalf("OAuth Kiro endpoint = %+v, err=%v", resolved, err) + } +} + func TestEndpointsForAccountUsesCLIForAPIKey(t *testing.T) { eps := endpointsForAccount(&config.Account{AuthMethod: "api_key", KiroApiKey: "ksk_x"}) if len(eps) != 1 || eps[0].Name != "Kiro CLI" { @@ -277,8 +403,9 @@ func TestEndpointsForAccountUsesCLIForAPIKey(t *testing.T) { if eps[0].Origin != "KIRO_CLI" { t.Fatalf("origin = %q", eps[0].Origin) } - if got := cliRuntimeURL(&config.Account{Region: "eu-central-1"}); got != "https://runtime.eu-central-1.kiro.dev/" { - t.Fatalf("cli url = %q", got) + resolved, err := resolveKiroEndpoint(eps[0], &config.Account{Region: "eu-central-1", AuthMethod: "api_key", KiroApiKey: "ksk_x"}, "") + if err != nil || resolved.URL != "https://runtime.eu-central-1.kiro.dev/" { + t.Fatalf("cli endpoint = %+v, err=%v", resolved, err) } } @@ -301,7 +428,7 @@ func assertProxyURL(t *testing.T, got *url.URL, want string) { } } -func awsEventStreamFrame(t *testing.T, eventType string, payload map[string]interface{}) []byte { +func awsEventStreamMessage(t *testing.T, fields map[string]string, payload interface{}) []byte { t.Helper() payloadBytes, err := json.Marshal(payload) @@ -309,20 +436,34 @@ func awsEventStreamFrame(t *testing.T, eventType string, payload map[string]inte t.Fatalf("marshal payload: %v", err) } - headerValue := []byte(eventType) - headers := make([]byte, 0, 1+len(":event-type")+1+2+len(headerValue)) - headers = append(headers, byte(len(":event-type"))) - headers = append(headers, []byte(":event-type")...) - headers = append(headers, byte(7)) - headers = append(headers, byte(len(headerValue)>>8), byte(len(headerValue))) - headers = append(headers, headerValue...) + var headers []byte + for _, name := range []string{":message-type", ":event-type", ":exception-type", ":error-code", ":error-message"} { + value, ok := fields[name] + if !ok { + continue + } + headers = append(headers, byte(len(name))) + headers = append(headers, name...) + headers = append(headers, byte(7)) + headers = append(headers, byte(len(value)>>8), byte(len(value))) + headers = append(headers, value...) + } totalLength := 12 + len(headers) + len(payloadBytes) + 4 - frame := make([]byte, 12, totalLength) + frame := make([]byte, totalLength) binary.BigEndian.PutUint32(frame[0:4], uint32(totalLength)) binary.BigEndian.PutUint32(frame[4:8], uint32(len(headers))) - frame = append(frame, headers...) - frame = append(frame, payloadBytes...) - frame = append(frame, 0, 0, 0, 0) + binary.BigEndian.PutUint32(frame[8:12], crc32.ChecksumIEEE(frame[:8])) + copy(frame[12:], headers) + copy(frame[12+len(headers):], payloadBytes) + binary.BigEndian.PutUint32(frame[totalLength-4:], crc32.ChecksumIEEE(frame[:totalLength-4])) return frame } + +func awsEventStreamFrame(t *testing.T, eventType string, payload map[string]interface{}) []byte { + t.Helper() + return awsEventStreamMessage(t, map[string]string{ + ":message-type": "event", + ":event-type": eventType, + }, payload) +} diff --git a/proxy/stream_drop_test.go b/proxy/stream_drop_test.go new file mode 100644 index 00000000..e595ba75 --- /dev/null +++ b/proxy/stream_drop_test.go @@ -0,0 +1,271 @@ +package proxy + +// Regression coverage for the three stream-drop failure modes. +// Each case asserts the user-facing symptom so the suite goes red on a +// regression and green once the stream parser is correct. + +import ( + "bytes" + "errors" + "testing" +) + +// Truncation is undetectable when metadataEvent.stopReason is ignored: +// content arrives, the body ends, parseEventStream returns nil and fires +// OnComplete. Callers cannot tell this from a complete response. +// Kiro IDE retries on "content>0 && stopReason === undefined". +func TestParseEventStreamReportsStopReasonFromMetadata(t *testing.T) { + var stream bytes.Buffer + stream.Write(awsEventStreamFrame(t, "assistantResponseEvent", + map[string]interface{}{"content": "Let me check that file"})) + stream.Write(awsEventStreamFrame(t, "metadataEvent", + map[string]interface{}{"stopReason": "end_turn"})) + + var text, reason string + completed := false + err := parseEventStream(bytes.NewReader(stream.Bytes()), &KiroStreamCallback{ + OnText: func(s string, _ bool) { text += s }, + OnStopReason: func(r string) { reason = r }, + OnComplete: func(_, _ int) { completed = true }, + }) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if text != "Let me check that file" { + t.Fatalf("text = %q", text) + } + if reason != "end_turn" { + t.Fatalf("stopReason = %q, want end_turn", reason) + } + if !completed { + t.Fatal("expected OnComplete") + } +} + +// A truncated stream (content, no stopReason) must leave OnStopReason unset so +// callers can detect truncation. parseEventStream itself still returns nil — +// the signal is the missing callback, not an error. +func TestParseEventStreamTruncationLeavesStopReasonEmpty(t *testing.T) { + var stream bytes.Buffer + stream.Write(awsEventStreamFrame(t, "assistantResponseEvent", + map[string]interface{}{"content": "Let me check that file"})) + + var reason string + sawStop := false + err := parseEventStream(bytes.NewReader(stream.Bytes()), &KiroStreamCallback{ + OnText: func(s string, _ bool) {}, + OnStopReason: func(r string) { + sawStop = true + reason = r + }, + }) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + if sawStop { + t.Fatalf("truncated stream must not fire OnStopReason, got %q", reason) + } +} + +// Interleaved parallel tool-call frames must each emit exactly once with +// correct args. The previous single-slot state machine force-finished the +// previous tool on every id change, so the client executed the same tool +// multiple times. +func TestParseEventStreamInterleavedParallelToolCallsEmitOnce(t *testing.T) { + var stream bytes.Buffer + frame := func(payload map[string]interface{}) { + stream.Write(awsEventStreamFrame(t, "toolUseEvent", payload)) + } + frame(map[string]interface{}{"toolUseId": "call_A", "name": "read_file"}) + frame(map[string]interface{}{"toolUseId": "call_B", "name": "list_dir"}) + frame(map[string]interface{}{"toolUseId": "call_A", "name": "read_file", "input": `{"path":"a.go"}`}) + frame(map[string]interface{}{"toolUseId": "call_B", "name": "list_dir", "input": `{"path":"/tmp"}`}) + frame(map[string]interface{}{"toolUseId": "call_B", "name": "list_dir", "stop": true}) + + got := map[string]map[string]interface{}{} + var order []string + if err := parseEventStream(bytes.NewReader(stream.Bytes()), &KiroStreamCallback{ + OnToolUse: func(tu KiroToolUse) { + got[tu.ToolUseID] = tu.Input + order = append(order, tu.ToolUseID) + }, + }); err != nil { + t.Fatalf("parse: %v", err) + } + + if len(order) != 2 { + t.Fatalf("expected each tool emitted once (2 total), got %d: %v", len(order), order) + } + if p, _ := got["call_A"]["path"].(string); p != "a.go" { + t.Fatalf("call_A input=%v, want path=a.go", got["call_A"]) + } + if p, _ := got["call_B"]["path"].(string); p != "/tmp" { + t.Fatalf("call_B input=%v, want path=/tmp", got["call_B"]) + } +} + +// Incomplete tool argument JSON must not be emitted as empty input. +// finishToolUse used to discard the Unmarshal error and substitute {}, so the +// client executed a parameterless tool call. +func TestParseEventStreamDropsIncompleteToolArgs(t *testing.T) { + var stream bytes.Buffer + frame := func(payload map[string]interface{}) { + stream.Write(awsEventStreamFrame(t, "toolUseEvent", payload)) + } + frame(map[string]interface{}{"toolUseId": "call_X", "name": "write_file"}) + frame(map[string]interface{}{"toolUseId": "call_X", "name": "write_file", "input": `{"path":"main.go",`}) + frame(map[string]interface{}{"toolUseId": "call_X", "name": "write_file", "input": `"content":"pack`}) + // EOF: no stop frame, incomplete JSON. + + var emitted []KiroToolUse + err := parseEventStream(bytes.NewReader(stream.Bytes()), &KiroStreamCallback{ + OnToolUse: func(tu KiroToolUse) { emitted = append(emitted, tu) }, + }) + if !errors.Is(err, errIncompleteToolUse) { + t.Fatalf("parse error = %v, want incomplete tool error", err) + } + if len(emitted) != 0 { + t.Fatalf("expected incomplete tool args to be dropped, got %d: %+v", len(emitted), emitted) + } +} + +func TestParseEventStreamRejectsToolWithoutInput(t *testing.T) { + frame := awsEventStreamFrame(t, "toolUseEvent", map[string]interface{}{ + "toolUseId": "call_missing", "name": "write_file", "stop": true, + }) + var emitted []KiroToolUse + err := parseEventStream(bytes.NewReader(frame), &KiroStreamCallback{ + OnToolUse: func(tu KiroToolUse) { emitted = append(emitted, tu) }, + }) + if !errors.Is(err, errIncompleteToolUse) { + t.Fatalf("parse error = %v, want missing-input tool error", err) + } + if len(emitted) != 0 { + t.Fatalf("tool without input must not be emitted: %+v", emitted) + } +} + +func TestParseEventStreamAcceptsExplicitEmptyToolInput(t *testing.T) { + frame := awsEventStreamFrame(t, "toolUseEvent", map[string]interface{}{ + "toolUseId": "call_empty", "name": "list_items", "input": `{}`, "stop": true, + }) + var emitted []KiroToolUse + if err := parseEventStream(bytes.NewReader(frame), &KiroStreamCallback{ + OnToolUse: func(tu KiroToolUse) { emitted = append(emitted, tu) }, + }); err != nil { + t.Fatalf("parse explicit empty input: %v", err) + } + if len(emitted) != 1 || len(emitted[0].Input) != 0 { + t.Fatalf("explicit empty input = %+v", emitted) + } +} + +func TestClassifyStreamIntegrity(t *testing.T) { + for _, tc := range []struct { + name string + content int + tools int + stopReason string + sawReasoning bool + wantErr error + }{ + {"complete with stop", 12, 0, "end_turn", false, nil}, + {"complete with tools", 0, 1, "", false, nil}, + {"empty", 0, 0, "", false, errUpstreamEmptyResponse}, + {"truncated content", 8, 0, "", false, errUpstreamTruncatedResponse}, + {"reasoning only truncated", 0, 0, "", true, errUpstreamTruncatedResponse}, + } { + t.Run(tc.name, func(t *testing.T) { + got := classifyStreamIntegrity(tc.content, tc.tools, tc.stopReason, tc.sawReasoning) + if tc.wantErr == nil { + if got != nil { + t.Fatalf("got %v, want nil", got) + } + return + } + if got == nil || got.Error() != tc.wantErr.Error() { + t.Fatalf("got %v, want %v", got, tc.wantErr) + } + }) + } +} + +// Two tools left pending at EOF must be emitted in arrival order. The earlier +// map-based flush relied on Go's randomized map iteration, so a parallel tool +// turn cut short by EOF delivered the calls in a nondeterministic order. +func TestParseEventStreamFlushesPendingToolsInArrivalOrder(t *testing.T) { + var stream bytes.Buffer + frame := func(payload map[string]interface{}) { + stream.Write(awsEventStreamFrame(t, "toolUseEvent", payload)) + } + frame(map[string]interface{}{"toolUseId": "call_1", "name": "read_file", "input": `{"path":"a.go"}`}) + frame(map[string]interface{}{"toolUseId": "call_2", "name": "list_dir", "input": `{"path":"/tmp"}`}) + frame(map[string]interface{}{"toolUseId": "call_3", "name": "grep", "input": `{"q":"x"}`}) + body := stream.Bytes() + + // Repeat: a single pass can match by luck under map iteration. + for i := 0; i < 20; i++ { + var order []string + if err := parseEventStream(bytes.NewReader(body), &KiroStreamCallback{ + OnToolUse: func(tu KiroToolUse) { order = append(order, tu.ToolUseID) }, + }); err != nil { + t.Fatalf("parse: %v", err) + } + want := []string{"call_1", "call_2", "call_3"} + if len(order) != len(want) { + t.Fatalf("expected %d tool calls, got %v", len(want), order) + } + for j := range want { + if order[j] != want[j] { + t.Fatalf("tool flush order = %v, want %v (run %d)", order, want, i) + } + } + } +} + +func TestParseEventStreamReportsMixedCompleteAndIncompleteTools(t *testing.T) { + var stream bytes.Buffer + frame := func(payload map[string]interface{}) { + stream.Write(awsEventStreamFrame(t, "toolUseEvent", payload)) + } + frame(map[string]interface{}{"toolUseId": "call_ok", "name": "read_file", "input": `{"path":"a.go"}`, "stop": true}) + frame(map[string]interface{}{"toolUseId": "call_cut", "name": "write_file", "input": `{"path":"b.go",`}) + stream.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{"stopReason": "END_TURN"})) + + var emitted []KiroToolUse + err := parseEventStream(bytes.NewReader(stream.Bytes()), &KiroStreamCallback{ + OnToolUse: func(tu KiroToolUse) { emitted = append(emitted, tu) }, + }) + if !errors.Is(err, errIncompleteToolUse) { + t.Fatalf("mixed tool set error = %v, want incomplete tool error", err) + } + if len(emitted) != 0 { + t.Fatalf("invalid parallel tool set leaked tools before validation: %+v", emitted) + } +} + +func TestParseEventStreamKeepsPendingTargetAfterSiblingStops(t *testing.T) { + var stream bytes.Buffer + frame := func(payload map[string]interface{}) { + stream.Write(awsEventStreamFrame(t, "toolUseEvent", payload)) + } + frame(map[string]interface{}{"toolUseId": "call_a", "name": "read_file"}) + frame(map[string]interface{}{"toolUseId": "call_b", "name": "list_dir", "input": `{"path":"/tmp"}`, "stop": true}) + frame(map[string]interface{}{"input": `{"path":"a.go"}`, "stop": true}) + + var tools []KiroToolUse + if err := parseEventStream(bytes.NewReader(stream.Bytes()), &KiroStreamCallback{ + OnToolUse: func(tool KiroToolUse) { tools = append(tools, tool) }, + }); err != nil { + t.Fatalf("parse: %v", err) + } + if len(tools) != 2 { + t.Fatalf("tools = %+v, want two", tools) + } + if tools[0].ToolUseID != "call_a" || tools[0].Input["path"] != "a.go" { + t.Fatalf("ID-less continuation did not attach to call_a: %+v", tools[0]) + } + if tools[1].ToolUseID != "call_b" || tools[1].Input["path"] != "/tmp" { + t.Fatalf("call_b changed: %+v", tools[1]) + } +} diff --git a/proxy/tool_use_order_test.go b/proxy/tool_use_order_test.go new file mode 100644 index 00000000..954146cc --- /dev/null +++ b/proxy/tool_use_order_test.go @@ -0,0 +1,33 @@ +package proxy + +import ( + "bytes" + "testing" +) + +// A fragment without toolUseId opens a synthetic entry; when the real id arrives +// it must adopt that entry in place rather than appending a second one at the +// end of the arrival order. +func TestParseEventStreamRekeyKeepsArrivalPosition(t *testing.T) { + var stream bytes.Buffer + frame := func(payload map[string]interface{}) { + stream.Write(awsEventStreamFrame(t, "toolUseEvent", payload)) + } + frame(map[string]interface{}{"name": "first_tool", "input": `{"k":`}) + frame(map[string]interface{}{"toolUseId": "real_first", "name": "first_tool", "input": `1}`}) + frame(map[string]interface{}{"toolUseId": "second", "name": "second_tool", "input": `{"k":2}`}) + + var order []string + if err := parseEventStream(bytes.NewReader(stream.Bytes()), &KiroStreamCallback{ + OnToolUse: func(tu KiroToolUse) { order = append(order, tu.ToolUseID) }, + }); err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + if len(order) != 2 { + t.Fatalf("expected 2 tool calls, got %d: %v", len(order), order) + } + if order[0] != "real_first" || order[1] != "second" { + t.Fatalf("rekey lost arrival position: got %v, want [real_first second]", order) + } +} From 06f3c203a4215b45440e3147c8960c92b9dd4df1 Mon Sep 17 00:00:00 2001 From: asuan-dev <116167305+asuan-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:16:24 +0800 Subject: [PATCH 3/6] fix(stream): classify empty and truncated upstream turns Add same-account integrity retry for empty/truncated streams and reject incomplete tool arguments instead of forging {}. Successful-turn sealing is gated on terminal outcomes that are safe to continue from. --- proxy/account_failover.go | 58 ++++++ proxy/stream_integrity.go | 124 +++++++++++++ proxy/stream_integrity_test.go | 316 +++++++++++++++++++++++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 proxy/stream_integrity.go create mode 100644 proxy/stream_integrity_test.go diff --git a/proxy/account_failover.go b/proxy/account_failover.go index 02624a49..4af06769 100644 --- a/proxy/account_failover.go +++ b/proxy/account_failover.go @@ -1,6 +1,7 @@ package proxy import ( + "errors" "kiro-go/config" "kiro-go/logger" "strings" @@ -8,6 +9,63 @@ import ( const maxAccountRetryAttempts = 3 +// maxSameAccountStreamRetries mirrors Kiro IDE's empty/truncated response +// recovery: retry the same request a few times before giving up or rotating. +const maxSameAccountStreamRetries = 2 + +// errUpstreamEmptyResponse and errUpstreamTruncatedResponse are soft failures +// raised when the stream ended without a usable completion signal. They are +// retryable on the same account and must not mark the account unhealthy. +var ( + errUpstreamEmptyResponse = errors.New("upstream returned empty response without stop reason") + errUpstreamTruncatedResponse = errors.New("upstream truncated response without stop reason") + errIncompleteToolUse = errors.New("upstream returned incomplete tool arguments") +) + +// classifyStreamIntegrity decides whether an upstream stream that returned no +// transport error is actually complete. Mirrors Kiro IDE: +// - empty: no content, no tools, no stopReason +// - truncated: some content, no tools, no stopReason +// - complete: stopReason present, or tools present, or both +// +// A stopReason of any non-empty value counts as complete. +func classifyStreamIntegrity(contentChars, toolCallCount int, stopReason string, sawReasoning bool) error { + if strings.TrimSpace(stopReason) != "" { + return nil + } + if toolCallCount > 0 { + // Tool turns often complete without a separate stopReason frame; treat + // a delivered tool call as a terminal signal. + return nil + } + if contentChars == 0 && !sawReasoning { + return errUpstreamEmptyResponse + } + if contentChars > 0 { + return errUpstreamTruncatedResponse + } + // reasoning-only with no stopReason is still truncated for clients that + // expected a final answer. + return errUpstreamTruncatedResponse +} + +func isSuccessfulKiroTurn(stopReason string, toolCallCount int) bool { + if strings.TrimSpace(stopReason) == "" { + // Complete tool turns can legitimately omit metadataEvent.stopReason. + return toolCallCount > 0 + } + switch classifyKiroStopReason(stopReason) { + case kiroStopLength, kiroStopContextLimit, kiroStopFiltered: + return false + default: + return true + } +} + +func isStreamIntegrityError(err error) bool { + return errors.Is(err, errUpstreamEmptyResponse) || errors.Is(err, errUpstreamTruncatedResponse) +} + func isQuotaErrorMessage(msg string) bool { msg = strings.ToLower(msg) return strings.Contains(msg, "429") || strings.Contains(msg, "quota") diff --git a/proxy/stream_integrity.go b/proxy/stream_integrity.go new file mode 100644 index 00000000..1c079dfe --- /dev/null +++ b/proxy/stream_integrity.go @@ -0,0 +1,124 @@ +package proxy + +import ( + "errors" + "fmt" + "io" + "kiro-go/config" + "kiro-go/logger" +) + +type streamIntegrityState struct { + ContentChars int + ToolCount int + StopReason string + SawReasoning bool +} + +func (s streamIntegrityState) classify() error { + return classifyStreamIntegrity(s.ContentChars, s.ToolCount, s.StopReason, s.SawReasoning) +} + +func (s *streamIntegrityState) observeText(text string, isReasoning bool) { + if text == "" { + return + } + if isReasoning { + s.SawReasoning = true + return + } + s.ContentChars += len(text) +} + +func (s *streamIntegrityState) observeToolUse() { + s.ToolCount++ +} + +func (s *streamIntegrityState) observeStopReason(reason string) { + s.StopReason = reason +} + +func (s *streamIntegrityState) reset() { + *s = streamIntegrityState{} +} + +// runKiroWithIntegrityRetry calls CallKiroAPI and recovers empty/truncated +// upstream streams the way Kiro IDE does: retry the same request on the same +// account a few times before surfacing the failure. +// +// build is invoked at the start of every attempt (including the first) so the +// caller can close over fresh accumulators. state owns the integrity snapshot +// and is reset automatically between attempts. reset clears the caller's other +// per-attempt accumulators; it may be nil. +// canRetry reports whether a retry is still safe (for streaming: nothing has +// been flushed to the client yet). nil means always retryable. +// +// Return contract: +// - nil: complete success only +// - transport error from CallKiroAPI: caller should rotate/ban as usual +// - integrity error while still retryable: retries exhausted; caller should +// rotate account without treating it as an auth/quota failure +// - integrity error after client flush: caller must surface failure to the +// client (do not fake end_turn / normal completion). Retry is unsafe. +func runKiroWithIntegrityRetry( + account *config.Account, + payload *KiroPayload, + state *streamIntegrityState, + build func() *KiroStreamCallback, + reset func(), + canRetry func() bool, +) error { + label := accountEmailForLog(account) + if state == nil { + state = &streamIntegrityState{} + } + retryable := func() bool { + if canRetry == nil { + return true + } + return canRetry() + } + for attempt := 0; attempt <= maxSameAccountStreamRetries; attempt++ { + if attempt > 0 { + if reset != nil { + reset() + } + state.reset() + } + callback := build() + err := CallKiroAPI(account, payload, callback) + + var integrityErr error + if err != nil { + if !errors.Is(err, errIncompleteToolUse) && !errors.Is(err, io.ErrUnexpectedEOF) { + return err + } + integrityErr = fmt.Errorf("%w: %v", errUpstreamTruncatedResponse, err) + } else { + integrityErr = state.classify() + } + + if integrityErr == nil { + return nil + } + + if retryable() && attempt < maxSameAccountStreamRetries { + logger.Warnf("[StreamIntegrity] %v on %s; retrying same account (%d/%d)", + integrityErr, label, attempt+1, maxSameAccountStreamRetries) + continue + } + + if !retryable() { + // Bytes already reached the client; reissuing would duplicate output. + // Return the integrity error so callers emit an error event instead of + // finishing with a forged end_turn/tool_use success (kiro2cc-proxy #13). + logger.Warnf("[StreamIntegrity] %v after client flush; signaling error (no retry)", integrityErr) + return integrityErr + } + + logger.Warnf("[StreamIntegrity] giving up after retries: %v", integrityErr) + return integrityErr + } + + return errUpstreamTruncatedResponse +} diff --git a/proxy/stream_integrity_test.go b/proxy/stream_integrity_test.go new file mode 100644 index 00000000..490e24ab --- /dev/null +++ b/proxy/stream_integrity_test.go @@ -0,0 +1,316 @@ +package proxy + +import ( + "kiro-go/config" + "net/http" + "net/http/httptest" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +// First upstream response is empty (no content, no stopReason). The helper must +// retry on the same account and succeed once a complete frame pair arrives. +func TestRunKiroWithIntegrityRetryRecoversEmptyThenComplete(t *testing.T) { + if err := config.Init(filepath.Join(t.TempDir(), "config.json")); err != nil { + t.Fatalf("config.Init: %v", err) + } + if err := config.UpdatePreferredEndpoint("kiro"); err != nil { + t.Fatalf("set endpoint: %v", err) + } + if err := config.UpdateEndpointFallback(false); err != nil { + t.Fatalf("disable fallback: %v", err) + } + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := hits.Add(1) + w.WriteHeader(http.StatusOK) + if n == 1 { + // Transport OK but empty body => empty-response integrity failure. + return + } + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{ + "content": "recovered", + })) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{ + "stopReason": "end_turn", + })) + })) + defer server.Close() + + oldEndpoints := kiroEndpoints + kiroEndpoints = []kiroEndpoint{{URL: server.URL, Origin: "AI_EDITOR", Name: "test"}} + defer func() { kiroEndpoints = oldEndpoints }() + + oldClient := kiroHttpStore.Load() + kiroHttpStore.Store(&http.Client{Timeout: time.Second, Transport: &http.Transport{}}) + defer kiroHttpStore.Store(oldClient) + + account := &config.Account{ + ID: "acc", + Email: "acc@test", + AccessToken: "token", + ProfileArn: "arn:aws:codewhisperer:profile/test", + } + payload := &KiroPayload{} + payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{ + Content: "hi", + Origin: "AI_EDITOR", + } + + var content string + var resets int + state := streamIntegrityState{} + err := runKiroWithIntegrityRetry(account, payload, + &state, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnText: func(s string, reasoning bool) { state.observeText(s, reasoning); content += s }, + OnStopReason: state.observeStopReason, + } + }, + func() { + resets++ + content = "" + }, + nil, + ) + if err != nil { + t.Fatalf("expected recovery, got %v", err) + } + if hits.Load() != 2 { + t.Fatalf("upstream hits = %d, want exactly one retry", hits.Load()) + } + if resets != 1 { + t.Fatalf("resets = %d, want exactly one reset", resets) + } + if content != "recovered" || state.StopReason != "end_turn" { + t.Fatalf("content=%q stopReason=%q", content, state.StopReason) + } +} + +func TestRunKiroWithIntegrityRetryRecoversTruncatedFrame(t *testing.T) { + if err := config.Init(filepath.Join(t.TempDir(), "config.json")); err != nil { + t.Fatalf("config.Init: %v", err) + } + if err := config.UpdatePreferredEndpoint("kiro"); err != nil { + t.Fatalf("set endpoint: %v", err) + } + if err := config.UpdateEndpointFallback(false); err != nil { + t.Fatalf("disable fallback: %v", err) + } + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hits.Add(1) == 1 { + frame := awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "cut"}) + _, _ = w.Write(frame[:len(frame)-3]) + return + } + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "recovered"})) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{"stopReason": "END_TURN"})) + })) + defer server.Close() + defer swapKiroEndpointsForTest(t, server)() + + account := &config.Account{ID: "acc", AccessToken: "token", ProfileArn: "arn:aws:codewhisperer:profile/test"} + payload := &KiroPayload{} + payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{Content: "hi", Origin: "AI_EDITOR"} + state := streamIntegrityState{} + var content string + err := runKiroWithIntegrityRetry(account, payload, &state, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnText: func(s string, reasoning bool) { state.observeText(s, reasoning); content += s }, + OnStopReason: state.observeStopReason, + } + }, + func() { content = "" }, + nil, + ) + if err != nil { + t.Fatalf("expected partial-frame recovery, got %v", err) + } + if hits.Load() != 2 || content != "recovered" || state.StopReason != "END_TURN" { + t.Fatalf("hits=%d content=%q stopReason=%q", hits.Load(), content, state.StopReason) + } +} + +// Once the client has already been flushed, an incomplete stream must not be +// retried (would duplicate output). Helper returns the integrity error so the +// caller can emit an error event instead of forging a normal completion. +func TestRunKiroWithIntegrityRetrySkipsRetryAfterClientFlush(t *testing.T) { + if err := config.Init(filepath.Join(t.TempDir(), "config.json")); err != nil { + t.Fatalf("config.Init: %v", err) + } + if err := config.UpdatePreferredEndpoint("kiro"); err != nil { + t.Fatalf("set endpoint: %v", err) + } + if err := config.UpdateEndpointFallback(false); err != nil { + t.Fatalf("disable fallback: %v", err) + } + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{ + "content": "partial", + })) + // no metadataEvent/stopReason => truncated + })) + defer server.Close() + + oldEndpoints := kiroEndpoints + kiroEndpoints = []kiroEndpoint{{URL: server.URL, Origin: "AI_EDITOR", Name: "test"}} + defer func() { kiroEndpoints = oldEndpoints }() + + oldClient := kiroHttpStore.Load() + kiroHttpStore.Store(&http.Client{Timeout: time.Second, Transport: &http.Transport{}}) + defer kiroHttpStore.Store(oldClient) + + account := &config.Account{ID: "acc", AccessToken: "token", ProfileArn: "arn:aws:codewhisperer:profile/test"} + payload := &KiroPayload{} + payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{Content: "hi", Origin: "AI_EDITOR"} + + var content string + flushed := false + state := streamIntegrityState{} + err := runKiroWithIntegrityRetry(account, payload, + &state, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnText: func(s string, reasoning bool) { + state.observeText(s, reasoning) + flushed = true + content += s + }, + } + }, + func() { content = "" }, + func() bool { return !flushed }, + ) + if !isStreamIntegrityError(err) { + t.Fatalf("expected integrity error after flush, got %v", err) + } + + if hits.Load() != 1 { + t.Fatalf("must not retry after flush, hits=%d", hits.Load()) + } + if content != "partial" { + t.Fatalf("content=%q", content) + } +} + +func TestSuccessfulKiroTurnSealGate(t *testing.T) { + for _, reason := range []string{"MAX_TOKENS", "MAX_OUTPUT_TOKENS", "LENGTH", "MODEL_CONTEXT_WINDOW_EXCEEDED", "CONTENT_FILTERED", "REFUSAL"} { + if isSuccessfulKiroTurn(reason, 0) { + t.Fatalf("%s must not qualify for successful sealing", reason) + } + } + if !isSuccessfulKiroTurn("END_TURN", 0) { + t.Fatal("END_TURN must qualify for sealing") + } + if !isSuccessfulKiroTurn("", 1) { + t.Fatal("tool turn without metadata stop must qualify for sealing") + } + if isSuccessfulKiroTurn("", 0) { + t.Fatal("empty terminal signal must not qualify for sealing") + } +} + +func TestProtocolStopReasonMappings(t *testing.T) { + if got := mapClaudeStopReason("END_TURN", 0); got != "end_turn" { + t.Fatalf("Claude END_TURN = %q", got) + } + if got := mapClaudeStopReason("MAX_TOKENS", 0); got != "max_tokens" { + t.Fatalf("Claude MAX_TOKENS = %q", got) + } + if got := mapClaudeStopReason("END_TURN", 1); got != "tool_use" { + t.Fatalf("Claude tool turn = %q", got) + } + if got := mapOpenAIFinishReason("MAX_TOKENS", 0); got != "length" { + t.Fatalf("OpenAI MAX_TOKENS = %q", got) + } + if got := mapOpenAIFinishReason("CONTENT_FILTER", 0); got != "content_filter" { + t.Fatalf("OpenAI CONTENT_FILTER = %q", got) + } + if got := mapClaudeStopReason("CONTENT_FILTERED", 0); got != "refusal" { + t.Fatalf("Claude CONTENT_FILTERED = %q", got) + } + if got := mapOpenAIFinishReason("CONTENT_FILTERED", 0); got != "content_filter" { + t.Fatalf("OpenAI CONTENT_FILTERED = %q", got) + } + if got := mapOpenAIFinishReason("END_TURN", 1); got != "tool_calls" { + t.Fatalf("OpenAI tool turn = %q", got) + } + status, reason := mapResponsesCompletion("MAX_TOKENS") + if status != "incomplete" || reason != "max_output_tokens" { + t.Fatalf("Responses MAX_TOKENS = status %q reason %q", status, reason) + } + status, reason = mapResponsesCompletion("CONTENT_FILTERED") + if status != "incomplete" || reason != "content_filter" { + t.Fatalf("Responses CONTENT_FILTERED = status %q reason %q", status, reason) + } + status, reason = mapResponsesCompletion("END_TURN") + if status != "completed" || reason != "" { + t.Fatalf("Responses END_TURN = status %q reason %q", status, reason) + } +} + +func TestRunKiroWithIntegrityRetryRetriesMixedToolSet(t *testing.T) { + if err := config.Init(filepath.Join(t.TempDir(), "config.json")); err != nil { + t.Fatalf("config.Init: %v", err) + } + if err := config.UpdatePreferredEndpoint("kiro"); err != nil { + t.Fatalf("set endpoint: %v", err) + } + if err := config.UpdateEndpointFallback(false); err != nil { + t.Fatalf("disable fallback: %v", err) + } + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hits.Add(1) == 1 { + _, _ = w.Write(awsEventStreamFrame(t, "toolUseEvent", map[string]interface{}{ + "toolUseId": "call_ok", "name": "read_file", "input": `{"path":"a.go"}`, "stop": true, + })) + _, _ = w.Write(awsEventStreamFrame(t, "toolUseEvent", map[string]interface{}{ + "toolUseId": "call_cut", "name": "write_file", "input": `{"path":"b.go",`, "stop": true, + })) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{"stopReason": "END_TURN"})) + return + } + _, _ = w.Write(awsEventStreamFrame(t, "toolUseEvent", map[string]interface{}{ + "toolUseId": "call_fresh", "name": "read_file", "input": `{"path":"fresh.go"}`, "stop": true, + })) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{"stopReason": "TOOL_USE"})) + })) + defer server.Close() + defer swapKiroEndpointsForTest(t, server)() + + account := &config.Account{ID: "acc", AccessToken: "token", ProfileArn: "arn:aws:codewhisperer:profile/test"} + payload := &KiroPayload{} + payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{Content: "hi", Origin: "AI_EDITOR"} + var tools []KiroToolUse + state := streamIntegrityState{} + err := runKiroWithIntegrityRetry(account, payload, &state, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnToolUse: func(tu KiroToolUse) { state.observeToolUse(); tools = append(tools, tu) }, + OnStopReason: state.observeStopReason, + } + }, + func() { tools = nil }, + nil, + ) + if err != nil { + t.Fatalf("expected retry recovery, got %v", err) + } + if hits.Load() != 2 { + t.Fatalf("upstream hits = %d, want retry after mixed tool set", hits.Load()) + } + if len(tools) != 1 || tools[0].ToolUseID != "call_fresh" { + t.Fatalf("partial first attempt leaked into recovered tool set: %+v", tools) + } +} From 121051824efed3bc14e2202e139b30804c9109b9 Mon Sep 17 00:00:00 2001 From: asuan-dev <116167305+asuan-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:16:24 +0800 Subject: [PATCH 4/6] feat(proxy): exact session continuity and reasoning stamp reattach Mint conversation/continuation ids once and reuse them only on an exact client-transcript match. Store thinking signatures by exact turn key and reattach on the next request from the handler path, without fuzzy matching. --- proxy/reasoning_store.go | 240 +++++++++++++++++++++ proxy/reasoning_store_test.go | 392 ++++++++++++++++++++++++++++++++++ proxy/session_store.go | 336 +++++++++++++++++++++++++++++ proxy/session_store_test.go | 375 ++++++++++++++++++++++++++++++++ 4 files changed, 1343 insertions(+) create mode 100644 proxy/reasoning_store.go create mode 100644 proxy/reasoning_store_test.go create mode 100644 proxy/session_store.go create mode 100644 proxy/session_store_test.go diff --git a/proxy/reasoning_store.go b/proxy/reasoning_store.go new file mode 100644 index 00000000..70319a6b --- /dev/null +++ b/proxy/reasoning_store.go @@ -0,0 +1,240 @@ +package proxy + +import ( + "crypto/sha1" + "encoding/base64" + "encoding/hex" + "sort" + "strings" + "sync" + "time" +) + +// sealedReasoning is one successful assistant turn's upstream thinking stamp. +// Official client keeps this in process memory (lastSealedReasoning) and pastes +// it onto that assistant message on the next request. As a stateless proxy we +// re-derive the same binding with a turn fingerprint so we never attach a stamp +// to the wrong conversation. +type sealedReasoning struct { + Text string + Signature string + RedactedContent string + ModelID string + StoredAt time.Time +} + +const ( + reasoningStoreTTL = 2 * time.Hour + reasoningStoreMaxEntries = 4096 +) + +type reasoningStore struct { + mu sync.Mutex + // key -> stamp. key = turnKey(...) + byKey map[string]sealedReasoning +} + +func newReasoningStore() *reasoningStore { + return &reasoningStore{byKey: make(map[string]sealedReasoning)} +} + +var globalReasoningStore = newReasoningStore() + +// turnKey builds the exact-match address for one assistant turn. +// +// apiKey | model | conversationBucket | hash(parentUser, assistantText, ordered tool uses) +// +// No fuzzy matching. Same account + same model alone is never enough. +func turnKey(apiKeyID, modelID, conversationID, parentUser, assistantText string, toolUses []KiroToolUse) string { + h := sha1.New() + h.Write([]byte(parentUser)) + h.Write([]byte{0}) + h.Write([]byte(assistantText)) + h.Write([]byte{0}) + for _, tu := range toolUses { + h.Write([]byte(tu.ToolUseID)) + h.Write([]byte{0}) + h.Write([]byte(tu.Name)) + h.Write([]byte{0}) + } + fp := hex.EncodeToString(h.Sum(nil)) + return strings.Join([]string{ + strings.TrimSpace(apiKeyID), + strings.TrimSpace(modelID), + strings.TrimSpace(conversationID), + fp, + }, "|") +} + +// put seals a successful turn. Unsigned reasoning (no signature and no +// redacted blob) is dropped — same rule as the official client. +func (s *reasoningStore) put(apiKeyID, modelID, conversationID, parentUser, assistantText string, toolUses []KiroToolUse, text, signature, redacted string) { + if signature == "" && redacted == "" { + return + } + if text == "" && redacted == "" { + return + } + key := turnKey(apiKeyID, modelID, conversationID, parentUser, assistantText, toolUses) + s.mu.Lock() + defer s.mu.Unlock() + s.purgeLocked(time.Now()) + if len(s.byKey) >= reasoningStoreMaxEntries { + // Drop a quarter of entries when at capacity. + s.dropOldestLocked(reasoningStoreMaxEntries / 4) + } + s.byKey[key] = sealedReasoning{ + Text: text, + Signature: signature, + RedactedContent: redacted, + ModelID: modelID, + StoredAt: time.Now(), + } +} + +// lookup returns a stamp only on exact turn-key hit + model match + not expired. +// Miss → nil. Never guesses. +func (s *reasoningStore) lookup(apiKeyID, modelID, conversationID, parentUser, assistantText string, toolUses []KiroToolUse) *sealedReasoning { + key := turnKey(apiKeyID, modelID, conversationID, parentUser, assistantText, toolUses) + s.mu.Lock() + defer s.mu.Unlock() + s.purgeLocked(time.Now()) + e, ok := s.byKey[key] + if !ok { + return nil + } + if modelID != "" && e.ModelID != "" && e.ModelID != modelID { + return nil + } + cp := e + return &cp +} + +func (s *reasoningStore) purgeLocked(now time.Time) { + for k, e := range s.byKey { + if now.Sub(e.StoredAt) > reasoningStoreTTL { + delete(s.byKey, k) + } + } +} + +func (s *reasoningStore) dropOldestLocked(n int) { + type pair struct { + k string + t time.Time + } + all := make([]pair, 0, len(s.byKey)) + for k, e := range s.byKey { + all = append(all, pair{k, e.StoredAt}) + } + sort.Slice(all, func(i, j int) bool { return all[i].t.Before(all[j].t) }) + if n > len(all) { + n = len(all) + } + for i := 0; i < n; i++ { + delete(s.byKey, all[i].k) + } +} + +// toKiroReasoning converts a sealed stamp into the wire reasoningContent object. +func toKiroReasoning(e *sealedReasoning) *KiroReasoningContent { + if e == nil { + return nil + } + if e.RedactedContent != "" { + raw, err := base64.StdEncoding.DecodeString(e.RedactedContent) + if err != nil || len(raw) == 0 { + return nil + } + return &KiroReasoningContent{RedactedContent: raw} + } + if e.Signature == "" { + return nil + } + rt := struct { + Text string `json:"text"` + Signature string `json:"signature"` + }{Text: e.Text, Signature: e.Signature} + return &KiroReasoningContent{ReasoningText: &rt} +} + +// lastUserTextBeforeAssistant returns the nearest preceding user text for an +// assistant history index. Used only for turn-key parent binding. +func lastUserTextBeforeAssistant(history []KiroHistoryMessage, assistantIdx int) string { + for i := assistantIdx - 1; i >= 0; i-- { + if history[i].UserInputMessage != nil { + return history[i].UserInputMessage.Content + } + } + return "" +} + +// attachStoredReasoning walks history and pastes exact-match stamps onto +// assistant turns. No fuzzy fill. Safe to call every request. +func attachStoredReasoning(history []KiroHistoryMessage, apiKeyID, modelID, conversationID string) { + if len(history) == 0 { + return + } + for i := range history { + as := history[i].AssistantResponseMessage + if as == nil || as.ReasoningContent != nil { + continue + } + parent := lastUserTextBeforeAssistant(history, i) + if e := globalReasoningStore.lookup(apiKeyID, modelID, conversationID, parent, as.Content, as.ToolUses); e != nil { + as.ReasoningContent = toKiroReasoning(e) + } + } +} + +// sealSuccessfulTurn stores the stamp for this completed turn. +// currentTurnParentUser is the user text of the turn being answered: always +// payload.CurrentMessage, never the last user already in History. +func currentTurnParentUser(payload *KiroPayload) string { + if payload == nil { + return "" + } + return payload.ConversationState.CurrentMessage.UserInputMessage.Content +} + +func sealSuccessfulTurn(apiKeyID, modelID, conversationID, parentUser, assistantText string, toolUses []KiroToolUse, thinkingText, signature, redacted string) { + globalReasoningStore.put(apiKeyID, modelID, conversationID, parentUser, assistantText, toolUses, thinkingText, signature, redacted) +} + +// debugTurnKey is for package tests. +func debugTurnKey(apiKeyID, modelID, conversationID, parentUser, assistantText string, toolUses []KiroToolUse) string { + return turnKey(apiKeyID, modelID, conversationID, parentUser, assistantText, toolUses) +} + +// reasoningCapture accumulates OnReasoningMeta for one upstream attempt. +type reasoningCapture struct { + sig, red string +} + +func (c *reasoningCapture) meta() func(string, string) { + return func(sig, red string) { + if c == nil { + return + } + if sig != "" { + c.sig = sig + } + if red != "" { + c.red = red + } + } +} + +func (c *reasoningCapture) reset() { + if c == nil { + return + } + c.sig, c.red = "", "" +} + +func (c *reasoningCapture) sealIfPresent(apiKeyID, model, convID, parent, assistant string, tools []KiroToolUse, thinkingText string) { + if c == nil || (c.sig == "" && c.red == "") { + return + } + sealSuccessfulTurn(apiKeyID, model, convID, parent, assistant, tools, thinkingText, c.sig, c.red) +} diff --git a/proxy/reasoning_store_test.go b/proxy/reasoning_store_test.go new file mode 100644 index 00000000..06cb369d --- /dev/null +++ b/proxy/reasoning_store_test.go @@ -0,0 +1,392 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "testing" +) + +// Seam: turnKey — same turn content must hash equal; different assistant text must not. +func TestTurnKeyStableAndIsolatesAssistantText(t *testing.T) { + a := debugTurnKey("k1", "claude-sonnet-4.5", "conv-a", "hello", "answer one", nil) + b := debugTurnKey("k1", "claude-sonnet-4.5", "conv-a", "hello", "answer one", nil) + if a != b { + t.Fatalf("same turn must produce same key") + } + c := debugTurnKey("k1", "claude-sonnet-4.5", "conv-a", "hello", "answer two", nil) + if a == c { + t.Fatalf("different assistant text must not share a key") + } +} + +// Seam: store — two parallel chats with the same opener must not share stamps. +func TestReasoningStoreDoesNotCrossConversations(t *testing.T) { + s := newReasoningStore() + tools := []KiroToolUse{} + s.put("keyA", "m1", "conv1", "hi", "reply-A", tools, "think-A", "sig-A", "") + s.put("keyA", "m1", "conv1", "hi", "reply-B", tools, "think-B", "sig-B", "") + + // Lookup for reply-A must not return B's stamp. + got := s.lookup("keyA", "m1", "conv1", "hi", "reply-A", tools) + if got == nil || got.Signature != "sig-A" { + t.Fatalf("expected sig-A for reply-A, got %+v", got) + } + // Different conversation bucket, same text — miss. + if s.lookup("keyA", "m1", "conv-OTHER", "hi", "reply-A", tools) != nil { + t.Fatal("must not hit across conversation buckets") + } + // Different API key — miss. + if s.lookup("keyB", "m1", "conv1", "hi", "reply-A", tools) != nil { + t.Fatal("must not hit across API keys") + } + // Different model — miss. + if s.lookup("keyA", "other-model", "conv1", "hi", "reply-A", tools) != nil { + t.Fatal("must not hit across models") + } +} + +// Seam: store — unsigned reasoning is never stored (official drops it). +func TestReasoningStoreDropsUnsigned(t *testing.T) { + s := newReasoningStore() + s.put("k", "m", "c", "u", "a", nil, "thinking text", "", "") + if s.lookup("k", "m", "c", "u", "a", nil) != nil { + t.Fatal("unsigned reasoning must not be stored") + } +} + +// Seam: parseEventStream — signature on reasoningContentEvent must reach OnReasoningMeta. +func TestParseEventStreamFiresReasoningMetaWithSignature(t *testing.T) { + var stream bytes.Buffer + stream.Write(awsEventStreamFrame(t, "reasoningContentEvent", map[string]interface{}{ + "text": "I should check the file", + "signature": "sig_abc123", + })) + stream.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{ + "content": "done", + })) + stream.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{ + "stopReason": "end_turn", + })) + + var thinking, sig string + err := parseEventStream(bytes.NewReader(stream.Bytes()), &KiroStreamCallback{ + OnText: func(text string, isThinking bool) { + if isThinking { + thinking += text + } + }, + OnReasoningMeta: func(signature, _ string) { sig = signature }, + }) + if err != nil { + t.Fatalf("parse: %v", err) + } + if thinking != "I should check the file" { + t.Fatalf("thinking text = %q", thinking) + } + if sig != "sig_abc123" { + t.Fatalf("signature = %q, want sig_abc123", sig) + } +} + +// Seam: attachStoredReasoning — only exact-matching assistant turns get stamps. +func TestAttachStoredReasoningExactMatchOnly(t *testing.T) { + // Reset global store by sealing via public API then attaching. + // Use unique conversation id to avoid pollution from other tests. + conv := "conv-attach-test" + apiKey := "ak-attach" + model := "claude-sonnet-4.5" + parent := "what is 1+1?" + assistant := "2" + sealSuccessfulTurn(apiKey, model, conv, parent, assistant, nil, "add the numbers", "sig-42", "") + + history := []KiroHistoryMessage{ + {UserInputMessage: &KiroUserInputMessage{Content: parent, Origin: "AI_EDITOR"}}, + {AssistantResponseMessage: &KiroAssistantResponseMessage{Content: assistant}}, + {UserInputMessage: &KiroUserInputMessage{Content: "and 2+2?", Origin: "AI_EDITOR"}}, + // Different assistant text — must stay unstamped. + {AssistantResponseMessage: &KiroAssistantResponseMessage{Content: "4"}}, + } + attachStoredReasoning(history, apiKey, model, conv) + + if history[1].AssistantResponseMessage.ReasoningContent == nil { + t.Fatal("matching turn must receive reasoningContent") + } + rt := history[1].AssistantResponseMessage.ReasoningContent.ReasoningText + if rt == nil || rt.Signature != "sig-42" || rt.Text != "add the numbers" { + t.Fatalf("bad stamp on matching turn: %+v", history[1].AssistantResponseMessage.ReasoningContent) + } + if history[3].AssistantResponseMessage.ReasoningContent != nil { + t.Fatal("non-matching turn must not receive a stamp") + } +} + +// Seam: ClaudeToKiro — after sealing turn N, the next request's history for that +// assistant message must carry reasoningContent for upstream. +func TestClaudeToKiroAttachesSealedReasoningOnNextTurn(t *testing.T) { + convSeedUser := "unique-opener-for-reasoning-tdd-xyz" + model := "claude-sonnet-4.5" + modelID := MapModel(model) + + // Turn 1: mint session ids the same way the handler/translator will. + turn1 := []ClaudeMessage{{Role: "user", Content: convSeedUser}} + p1 := ClaudeToKiro(&ClaudeRequest{Model: model, Messages: turn1}, true) + convID := p1.ConversationState.ConversationID + contID := p1.ConversationState.AgentContinuationId + + // After successful stream: seal reasoning + session chain (handler job). + sealSuccessfulTurn("", modelID, convID, convSeedUser, "first answer", nil, "think first", "sig-turn1", "") + sealSessionAfterSuccess("", modelID, convID, contID, []ClaudeMessage{ + {Role: "user", Content: convSeedUser}, + {Role: "assistant", Content: "first answer"}, + }) + + // Turn 2: continuation must reuse ids; attach must stamp first answer. + req := &ClaudeRequest{ + Model: model, + Messages: []ClaudeMessage{ + {Role: "user", Content: convSeedUser}, + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "first answer"}, + }}, + {Role: "user", Content: "follow up"}, + }, + } + payload := ClaudeToKiro(req, true) + // Handler binds session ids with apiKey (empty here). + c, k, _ := resolveSessionIDs("", modelID, req.Messages) + payload.ConversationState.ConversationID = c + payload.ConversationState.AgentContinuationId = k + if payload.ConversationState.ConversationID != convID { + t.Fatalf("conversationId = %q, want reused %q", payload.ConversationState.ConversationID, convID) + } + attachStoredReasoning(payload.ConversationState.History, "", modelID, payload.ConversationState.ConversationID) + var found bool + for _, h := range payload.ConversationState.History { + if h.AssistantResponseMessage == nil || h.AssistantResponseMessage.Content != "first answer" { + continue + } + found = true + rc := h.AssistantResponseMessage.ReasoningContent + if rc == nil || rc.ReasoningText == nil || rc.ReasoningText.Signature != "sig-turn1" { + t.Fatalf("assistant history missing reasoningContent: %+v", rc) + } + } + if !found { + t.Fatalf("assistant turn not in history: %s", mustJSON(payload.ConversationState.History)) + } +} + +func mustJSON(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) +} + +// Handler must seal with CurrentMessage as parent (the user just answered), +// not the last user already sitting in History (previous turn). +func TestSealUsesCurrentMessageParentNotHistory(t *testing.T) { + model := "claude-sonnet-4.5" + opener := "seal-parent-opener-unique-aaa" + follow := "seal-parent-follow-up-bbb" + modelID := MapModel(model) + + p1 := ClaudeToKiro(&ClaudeRequest{Model: model, Messages: []ClaudeMessage{{Role: "user", Content: opener}}}, true) + convID := p1.ConversationState.ConversationID + contID := p1.ConversationState.AgentContinuationId + + // Seal as handler: parent = CurrentMessage (opener), then session chain. + sealSuccessfulTurn("", modelID, convID, opener, "answer-1", nil, "think-1", "sig-1", "") + sealSessionAfterSuccess("", modelID, convID, contID, []ClaudeMessage{ + {Role: "user", Content: opener}, + {Role: "assistant", Content: "answer-1"}, + }) + + req := &ClaudeRequest{ + Model: model, + Messages: []ClaudeMessage{ + {Role: "user", Content: opener}, + {Role: "assistant", Content: []interface{}{map[string]interface{}{"type": "text", "text": "answer-1"}}}, + {Role: "user", Content: follow}, + }, + } + payload := ClaudeToKiro(req, true) + // Handler binds session ids with apiKey (empty here). + c, k, _ := resolveSessionIDs("", modelID, req.Messages) + payload.ConversationState.ConversationID = c + payload.ConversationState.AgentContinuationId = k + if payload.ConversationState.ConversationID != convID { + t.Fatalf("want reused conv %q, got %q", convID, payload.ConversationState.ConversationID) + } + attachStoredReasoning(payload.ConversationState.History, "", modelID, payload.ConversationState.ConversationID) + var stamped bool + for _, h := range payload.ConversationState.History { + if h.AssistantResponseMessage != nil && h.AssistantResponseMessage.Content == "answer-1" { + rc := h.AssistantResponseMessage.ReasoningContent + if rc == nil || rc.ReasoningText == nil || rc.ReasoningText.Signature != "sig-1" { + t.Fatalf("expected sig-1 on answer-1, got %+v", rc) + } + stamped = true + } + } + if !stamped { + t.Fatal("answer-1 not found/stamped in history") + } + if globalReasoningStore.lookup("", modelID, convID, follow, "answer-1", nil) != nil { + t.Fatal("lookup with wrong parent must miss") + } +} + +func TestRedactedReasoningRoundTripsBase64Once(t *testing.T) { + rc := toKiroReasoning(&sealedReasoning{RedactedContent: "AQID"}) + if rc == nil { + t.Fatal("expected redacted reasoning content") + } + wire, err := json.Marshal(rc) + if err != nil { + t.Fatalf("marshal redacted reasoning: %v", err) + } + if got, want := string(wire), `{"redactedContent":"AQID"}`; got != want { + t.Fatalf("wire reasoning = %s, want %s", got, want) + } +} + +func TestInvalidRedactedReasoningFailsClosed(t *testing.T) { + if rc := toKiroReasoning(&sealedReasoning{RedactedContent: "not base64"}); rc != nil { + t.Fatalf("invalid redacted content must not reach upstream: %+v", rc) + } +} + +func TestReasoningTurnKeyUsesExactTextAndToolIdentity(t *testing.T) { + if debugTurnKey("k", "m", "c", "a b", "answer", nil) == debugTurnKey("k", "m", "c", "a b", "answer", nil) { + t.Fatal("reasoning key must preserve exact parent whitespace") + } + toolsA := []KiroToolUse{ + {ToolUseID: "a", Name: "read", Input: map[string]interface{}{"path": "a.go"}}, + {ToolUseID: "b", Name: "list", Input: map[string]interface{}{"path": "/tmp"}}, + } + toolsB := []KiroToolUse{ + {ToolUseID: "a", Name: "read", Input: map[string]interface{}{"path": "b.go"}}, + {ToolUseID: "b", Name: "list", Input: map[string]interface{}{"path": "elsewhere"}}, + } + if debugTurnKey("k", "m", "c", "u", "a", toolsA) != debugTurnKey("k", "m", "c", "u", "a", toolsB) { + t.Fatal("reasoning key contract binds tool IDs/names, not arguments") + } + reversed := []KiroToolUse{toolsA[1], toolsA[0]} + if debugTurnKey("k", "m", "c", "u", "a", toolsA) == debugTurnKey("k", "m", "c", "u", "a", reversed) { + t.Fatal("reasoning key must preserve tool order") + } +} + +func TestReasoningReattachesAcrossRawToolResultParent(t *testing.T) { + globalReasoningStore = newReasoningStore() + globalSessionStore = newSessionStore() + const api = "key-tool-parent" + const model = "claude-sonnet-4.5" + const conv = "conv-tool-parent" + const cont = "cont-tool-parent" + + inbound := []ClaudeMessage{ + {Role: "user", Content: "read it"}, + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{"type": "tool_use", "id": "tu_1", "name": "read_file", "input": map[string]interface{}{"path": "a.go"}}, + }}, + {Role: "user", Content: []interface{}{ + map[string]interface{}{"type": "tool_result", "tool_use_id": "tu_1", "content": "file body"}, + }}, + } + // Production seals against the translated current-message parent, then + // reattaches via attachStoredReasoning on the next request. + // For pure tool_result turns, ClaudeToKiro folds the parent into: + // "Tool results:\n\n[read_file] file body". + stamp := &reasoningCapture{sig: "sig-after-tool"} + parent := "Tool results:\n\n[read_file] file body" + stamp.sealIfPresent(api, MapModel(model), conv, parent, "final answer", nil, "reason after tool") + sealClaudeSession(api, model, conv, cont, inbound, "final answer", "", nil, "thinking", false) + + req := &ClaudeRequest{Model: model, Messages: append(append([]ClaudeMessage(nil), inbound...), + ClaudeMessage{Role: "assistant", Content: "final answer"}, + ClaudeMessage{Role: "user", Content: "next"}, + )} + payload := ClaudeToKiro(req, true) + payload.ConversationState.ConversationID = conv + payload.ConversationState.AgentContinuationId = cont + attachStoredReasoning(payload.ConversationState.History, api, MapModel(model), conv) + for _, message := range payload.ConversationState.History { + asst := message.AssistantResponseMessage + if asst == nil || asst.Content != "final answer" { + continue + } + if asst.ReasoningContent == nil || asst.ReasoningContent.ReasoningText == nil || asst.ReasoningContent.ReasoningText.Signature != "sig-after-tool" { + t.Fatalf("post-tool assistant missing exact reasoning stamp: %+v", asst.ReasoningContent) + } + return + } + t.Fatal("post-tool assistant not found in translated history") +} + +func TestReasoningSealUsesClientVisibleThinkFormat(t *testing.T) { + globalReasoningStore = newReasoningStore() + const api = "key-think-key" + const model = "claude-sonnet-4.5" + const conv = "conv-think-key" + inbound := []ClaudeMessage{{Role: "user", Content: "question"}} + stamp := &reasoningCapture{sig: "sig-think-key"} + // Production seals with the client-visible assistant text, which for + // "think" format is already folded by claudeAssistantTurn. + visible := "hidden thoughtanswer" + stamp.sealIfPresent(api, MapModel(model), conv, "question", visible, nil, "hidden thought") + sealClaudeSession(api, model, conv, "cont-think-key", inbound, "answer", "hidden thought", nil, "think", false) + req := &ClaudeRequest{Model: model, Messages: []ClaudeMessage{ + {Role: "user", Content: "question"}, + {Role: "assistant", Content: visible}, + {Role: "user", Content: "next"}, + }} + payload := ClaudeToKiro(req, true) + payload.ConversationState.ConversationID = conv + attachStoredReasoning(payload.ConversationState.History, api, MapModel(model), conv) + for _, message := range payload.ConversationState.History { + asst := message.AssistantResponseMessage + if asst != nil && asst.Content == visible { + if asst.ReasoningContent == nil || asst.ReasoningContent.ReasoningText == nil || asst.ReasoningContent.ReasoningText.Signature != "sig-think-key" { + t.Fatalf("think-format key did not reattach: %+v", asst.ReasoningContent) + } + return + } + } + t.Fatal("think-format assistant not found") +} + +func TestHiddenReasoningStillSealsAndIncompleteTurnDoesNot(t *testing.T) { + globalReasoningStore = newReasoningStore() + globalSessionStore = newSessionStore() + const api = "key-hidden-reasoning" + const model = "claude-sonnet-4.5" + const conv = "conv-hidden-reasoning" + inbound := []ClaudeMessage{{Role: "user", Content: "question"}} + stamp := &reasoningCapture{sig: "sig-hidden"} + if !isSuccessfulKiroTurn("END_TURN", 0) { + t.Fatal("END_TURN must qualify for sealing") + } + stamp.sealIfPresent(api, MapModel(model), conv, "question", "answer", nil, "upstream hidden thought") + sealClaudeSession(api, model, conv, "cont-hidden", inbound, "answer", "upstream hidden thought", nil, "thinking", true) + if got := globalReasoningStore.lookup(api, MapModel(model), conv, "question", "answer", nil); got == nil || got.Signature != "sig-hidden" { + t.Fatalf("hidden reasoning was not stored: %+v", got) + } + + globalReasoningStore = newReasoningStore() + globalSessionStore = newSessionStore() + if isSuccessfulKiroTurn("MAX_TOKENS", 0) { + t.Fatal("MAX_TOKENS must not qualify for sealing") + } + // Production only seals after isSuccessfulKiroTurn; incomplete turns skip both stores. + if got := globalReasoningStore.lookup(api, MapModel(model), conv, "question", "answer", nil); got != nil { + t.Fatalf("incomplete reasoning leaked into store: %+v", got) + } + _, _, reused := resolveSessionIDs(api, MapModel(model), []ClaudeMessage{ + {Role: "user", Content: "question"}, + {Role: "assistant", Content: "answer"}, + {Role: "user", Content: "next"}, + }) + if reused { + t.Fatal("MAX_TOKENS turn must not seal session IDs") + } +} diff --git a/proxy/session_store.go b/proxy/session_store.go new file mode 100644 index 00000000..e0479df9 --- /dev/null +++ b/proxy/session_store.go @@ -0,0 +1,336 @@ +package proxy + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// sessionIDs are the two upstream continuity handles for one agent run. +type sessionIDs struct { + ConversationID string + ContinuationID string + Ambiguous bool + StoredAt time.Time +} + +const sessionStoreTTL = 2 * time.Hour +const sessionStoreMax = 4096 + +type sessionStore struct { + mu sync.Mutex + byKey map[string]sessionIDs +} + +func newSessionStore() *sessionStore { + return &sessionStore{byKey: make(map[string]sessionIDs)} +} + +var globalSessionStore = newSessionStore() + +// chainFingerprint hashes a normalized Claude message prefix. +// Used as the exact-match address for "this transcript ends here". +func chainFingerprintClaude(messages []ClaudeMessage) string { + h := sha1.New() + for _, msg := range messages { + h.Write([]byte(strings.ToLower(strings.TrimSpace(msg.Role)))) + h.Write([]byte{0}) + h.Write([]byte(normalizeClaudeMessageContent(msg.Content))) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + +func normalizeClaudeMessageContent(content interface{}) string { + if content == nil { + return "" + } + if text, ok := content.(string); ok { + return stringifyLoose([]map[string]interface{}{{ + "type": "text", + "text": text, + }}) + } + + blocks := contentBlocksAsMaps(content) + if blocks == nil { + return stringifyLoose(content) + } + normalized := make([]map[string]interface{}, 0, len(blocks)) + for _, block := range blocks { + typ, _ := block["type"].(string) + switch typ { + case "text", "input_text": + text, _ := block["text"].(string) + normalized = append(normalized, map[string]interface{}{ + "type": "text", + "text": text, + }) + case "thinking": + thinking, _ := block["thinking"].(string) + normalized = append(normalized, map[string]interface{}{ + "type": "thinking", + "thinking": thinking, + }) + case "tool_use": + input := block["input"] + if input == nil { + input = map[string]interface{}{} + } + normalized = append(normalized, map[string]interface{}{ + "type": "tool_use", + "id": block["id"], + "name": block["name"], + "input": input, + }) + case "tool_result": + result := map[string]interface{}{ + "type": "tool_result", + "tool_use_id": block["tool_use_id"], + "content": block["content"], + } + if isError, ok := block["is_error"]; ok { + result["is_error"] = isError + } + normalized = append(normalized, result) + default: + normalized = append(normalized, block) + } + } + return stringifyLoose(normalized) +} + +// stringifyLoose renders a structured value deterministically for +// fingerprinting. encoding/json sorts map keys, so equivalent in-memory map +// order does not change the chain address. Marshal failures fail closed. +func stringifyLoose(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return "" + } + return string(b) +} + +func sessionKey(apiKeyID, modelID, chainFP string) string { + return strings.Join([]string{ + strings.TrimSpace(apiKeyID), + strings.TrimSpace(modelID), + strings.TrimSpace(chainFP), + }, "|") +} + +// resolveSessionIDs returns reused IDs when the inbound transcript prefix exactly +// matches a previously successful seal; otherwise mints fresh UUIDs. +// +// prefix = messages without the trailing user turn being answered now. +// Only reuses when prefix is non-empty and ends with an assistant message +// (i.e. this is a true continuation, not a brand-new chat). +func resolveSessionIDs(apiKeyID, modelID string, messages []ClaudeMessage) (convID, contID string, reused bool) { + prefix := claudePrefixBeforeCurrentUser(messages) + if len(prefix) == 0 || !claudeEndsWithAssistant(prefix) { + return uuid.New().String(), uuid.New().String(), false + } + fp := chainFingerprintClaude(prefix) + if ids := globalSessionStore.lookup(apiKeyID, modelID, fp); ids != nil { + return ids.ConversationID, ids.ContinuationID, true + } + return uuid.New().String(), uuid.New().String(), false +} + +func claudePrefixBeforeCurrentUser(messages []ClaudeMessage) []ClaudeMessage { + if len(messages) == 0 { + return nil + } + last := messages[len(messages)-1] + if strings.EqualFold(last.Role, "user") { + return messages[:len(messages)-1] + } + // Already ends mid-assistant (unusual); treat full list as prefix. + return messages +} + +func claudeEndsWithAssistant(messages []ClaudeMessage) bool { + if len(messages) == 0 { + return false + } + return strings.EqualFold(messages[len(messages)-1].Role, "assistant") +} + +// sealSessionAfterSuccess records continuity for the transcript the client will +// hold after this successful assistant turn: inbound messages with the final +// user turn answered, i.e. prefix+assistant — callers pass the full chain that +// ends with the assistant content just produced. +func sealSessionAfterSuccess(apiKeyID, modelID, convID, contID string, chainEndingWithAssistant []ClaudeMessage) { + if convID == "" || contID == "" || len(chainEndingWithAssistant) == 0 { + return + } + if !claudeEndsWithAssistant(chainEndingWithAssistant) { + return + } + fp := chainFingerprintClaude(chainEndingWithAssistant) + globalSessionStore.put(apiKeyID, modelID, fp, convID, contID) +} + +func (s *sessionStore) put(apiKeyID, modelID, chainFP, convID, contID string) { + key := sessionKey(apiKeyID, modelID, chainFP) + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + s.purgeLocked(now) + + if existing, ok := s.byKey[key]; ok { + if existing.Ambiguous || existing.ConversationID != convID || existing.ContinuationID != contID { + s.byKey[key] = sessionIDs{Ambiguous: true, StoredAt: now} + return + } + existing.StoredAt = now + s.byKey[key] = existing + return + } + + if len(s.byKey) >= sessionStoreMax { + s.dropOldestLocked(sessionStoreMax / 4) + } + s.byKey[key] = sessionIDs{ + ConversationID: convID, + ContinuationID: contID, + StoredAt: now, + } +} + +func (s *sessionStore) lookup(apiKeyID, modelID, chainFP string) *sessionIDs { + key := sessionKey(apiKeyID, modelID, chainFP) + s.mu.Lock() + defer s.mu.Unlock() + s.purgeLocked(time.Now()) + e, ok := s.byKey[key] + if !ok || e.Ambiguous || e.ConversationID == "" || e.ContinuationID == "" { + return nil + } + cp := e + return &cp +} + +func (s *sessionStore) purgeLocked(now time.Time) { + for k, e := range s.byKey { + if now.Sub(e.StoredAt) > sessionStoreTTL { + delete(s.byKey, k) + } + } +} + +func (s *sessionStore) dropOldestLocked(n int) { + type pair struct { + key string + storedAt time.Time + } + all := make([]pair, 0, len(s.byKey)) + for k, e := range s.byKey { + all = append(all, pair{k, e.StoredAt}) + } + sort.Slice(all, func(i, j int) bool { return all[i].storedAt.Before(all[j].storedAt) }) + if n > len(all) { + n = len(all) + } + for _, p := range all[:n] { + delete(s.byKey, p.key) + } +} + +// sealClaudeSession records continuity after a successful Claude turn. +// +// The chain MUST be fingerprinted from the client transcript, never from the +// wire payload: ClaudeToKiro rewrites history (system priming pair, flattened +// tool turns, truncation), so a payload-derived chain can never match the +// lookup key that resolveSessionIDs computes from the next inbound request. +// +// assistantText must be the VISIBLE assistant text the client stores — not the +// integrity-check builder that may have tool name/input padding glued on. +// thinkingText + format must mirror how the handler exposed reasoning to the +// client, otherwise the next-turn lookup (which fingerprints thinking blocks +// and folded text the same way the client replays them) will miss. +func sealClaudeSession(apiKeyID, model, convID, contID string, inbound []ClaudeMessage, assistantText, thinkingText string, toolUses []KiroToolUse, format string, omitDisplay bool) { + if convID == "" || contID == "" || len(inbound) == 0 { + return + } + chain := make([]ClaudeMessage, 0, len(inbound)+1) + chain = append(chain, inbound...) + chain = append(chain, claudeAssistantTurn(assistantText, thinkingText, toolUses, format, omitDisplay)) + sealSessionAfterSuccess(apiKeyID, MapModel(model), convID, contID, chain) +} + +// sealClaudeSessionFromContent records continuity from the exact assistant +// content the client will store and replay. Mixed web_search responses include +// server_tool_use / web_search_tool_result blocks that are not raw Kiro tool +// uses, so fingerprinting must use that client-visible shape. +func sealClaudeSessionFromContent(apiKeyID, model, convID, contID string, inbound []ClaudeMessage, content []map[string]interface{}) { + if convID == "" || contID == "" || len(inbound) == 0 || len(content) == 0 { + return + } + blocks := make([]interface{}, 0, len(content)) + for _, block := range content { + blocks = append(blocks, block) + } + chain := make([]ClaudeMessage, 0, len(inbound)+1) + chain = append(chain, inbound...) + chain = append(chain, ClaudeMessage{Role: "assistant", Content: blocks}) + sealSessionAfterSuccess(apiKeyID, MapModel(model), convID, contID, chain) +} + +// claudeAssistantTurn renders this turn's answer in the same block shape the +// client will replay it as, so seal and lookup fingerprints agree. +func claudeAssistantTurn(text, thinkingText string, toolUses []KiroToolUse, format string, omitDisplay bool) ClaudeMessage { + includeThinkingBlock := thinkingText != "" + // Formats that fold reasoning into the text stream: seal the folded text the + // client actually saw, with no separate thinking block. + switch format { + case "think": + if thinkingText != "" { + text = "" + thinkingText + "" + text + thinkingText = "" + includeThinkingBlock = false + } + case "reasoning_content": + if thinkingText != "" { + text = thinkingText + text + thinkingText = "" + includeThinkingBlock = false + } + default: + // Native thinking with omitted display emits an explicit empty shell. + if omitDisplay && includeThinkingBlock { + thinkingText = "" + } + } + + needBlocks := includeThinkingBlock || len(toolUses) > 0 + if !needBlocks { + return ClaudeMessage{Role: "assistant", Content: text} + } + blocks := make([]interface{}, 0, len(toolUses)+2) + if includeThinkingBlock { + blocks = append(blocks, map[string]interface{}{"type": "thinking", "thinking": thinkingText}) + } + if text != "" { + blocks = append(blocks, map[string]interface{}{"type": "text", "text": text}) + } + for _, tu := range toolUses { + input := tu.Input + if input == nil { + input = map[string]interface{}{} + } + blocks = append(blocks, map[string]interface{}{ + "type": "tool_use", + "id": tu.ToolUseID, + "name": tu.Name, + "input": input, + }) + } + return ClaudeMessage{Role: "assistant", Content: blocks} +} diff --git a/proxy/session_store_test.go b/proxy/session_store_test.go new file mode 100644 index 00000000..ae0fb041 --- /dev/null +++ b/proxy/session_store_test.go @@ -0,0 +1,375 @@ +package proxy + +import ( + "testing" +) + +func TestResolveSessionIDsMintsFreshOnFirstTurn(t *testing.T) { + msgs := []ClaudeMessage{ + {Role: "user", Content: "hello first turn only"}, + } + c1, k1, reused := resolveSessionIDs("ak", "claude-sonnet-4.5", msgs) + if reused { + t.Fatal("first turn must not reuse") + } + if c1 == "" || k1 == "" { + t.Fatal("must mint both ids") + } + c2, k2, reused2 := resolveSessionIDs("ak", "claude-sonnet-4.5", msgs) + if reused2 { + t.Fatal("unsealed first turn must not reuse") + } + if c1 == c2 || k1 == k2 { + t.Fatalf("unsealed first turns must mint distinct ids: %s/%s vs %s/%s", c1, k1, c2, k2) + } +} + +func TestSessionSealThenResolveReusesExactChain(t *testing.T) { + model := "claude-sonnet-4.5" + api := "ak-session-1" + user1 := "session-continuity-opener-unique-111" + asst1 := "session-continuity-answer-unique-111" + + // After turn 1 success, client holds user1+asst1; we seal that chain. + chain1 := []ClaudeMessage{ + {Role: "user", Content: user1}, + {Role: "assistant", Content: asst1}, + } + conv, cont := "conv-fixed-aaa", "cont-fixed-bbb" + sealSessionAfterSuccess(api, model, conv, cont, chain1) + + // Turn 2: client sends user1+asst1+user2. Prefix before current user = chain1. + turn2 := []ClaudeMessage{ + {Role: "user", Content: user1}, + {Role: "assistant", Content: asst1}, + {Role: "user", Content: "follow up please"}, + } + gotConv, gotCont, reused := resolveSessionIDs(api, model, turn2) + if !reused { + t.Fatal("expected reuse on exact continuation chain") + } + if gotConv != conv || gotCont != cont { + t.Fatalf("reused ids = %s/%s, want %s/%s", gotConv, gotCont, conv, cont) + } +} + +func TestSessionDoesNotReuseAcrossAPIKeysOrModels(t *testing.T) { + model := "claude-sonnet-4.5" + chain := []ClaudeMessage{ + {Role: "user", Content: "iso-user"}, + {Role: "assistant", Content: "iso-asst"}, + } + sealSessionAfterSuccess("key-A", model, "c-A", "k-A", chain) + + turn := []ClaudeMessage{ + {Role: "user", Content: "iso-user"}, + {Role: "assistant", Content: "iso-asst"}, + {Role: "user", Content: "next"}, + } + _, _, reused := resolveSessionIDs("key-B", model, turn) + if reused { + t.Fatal("must not reuse across API keys") + } + _, _, reused = resolveSessionIDs("key-A", "other-model", turn) + if reused { + t.Fatal("must not reuse across models") + } +} + +func TestSessionDoesNotReuseWhenAssistantTextDiverges(t *testing.T) { + model := "m" + api := "ak" + sealSessionAfterSuccess(api, model, "c1", "k1", []ClaudeMessage{ + {Role: "user", Content: "q"}, + {Role: "assistant", Content: "answer-A"}, + }) + // Client rewrote history assistant text — chain fingerprint changes. + _, _, reused := resolveSessionIDs(api, model, []ClaudeMessage{ + {Role: "user", Content: "q"}, + {Role: "assistant", Content: "answer-TAMPERED"}, + {Role: "user", Content: "next"}, + }) + if reused { + t.Fatal("tampered assistant history must not reuse session ids") + } +} + +func TestSessionDoesNotReuseWhenWhitespaceDiverges(t *testing.T) { + globalSessionStore = newSessionStore() + sealSessionAfterSuccess("key-space", "model", "conv-space", "cont-space", []ClaudeMessage{ + {Role: "user", Content: "a b"}, + {Role: "assistant", Content: "answer"}, + }) + _, _, reused := resolveSessionIDs("key-space", "model", []ClaudeMessage{ + {Role: "user", Content: "a b"}, + {Role: "assistant", Content: "answer"}, + {Role: "user", Content: "next"}, + }) + if reused { + t.Fatal("different whitespace is different client history and must not reuse") + } +} + +func TestClaudeToKiroReusesSessionIDsOnContinuation(t *testing.T) { + model := "claude-sonnet-4.5" + api := "" + user1 := "ctk-session-opener-zzz" + asst1 := "ctk-session-answer-zzz" + + // Seal as handler would after turn 1. + sealSessionAfterSuccess(api, MapModel(model), "conv-ctk-1", "cont-ctk-1", []ClaudeMessage{ + {Role: "user", Content: user1}, + {Role: "assistant", Content: asst1}, + }) + + req := &ClaudeRequest{ + Model: model, + Messages: []ClaudeMessage{ + {Role: "user", Content: user1}, + {Role: "assistant", Content: asst1}, + {Role: "user", Content: "second question"}, + }, + } + // Handler-side resolve (ClaudeToKiro stays pure). + gotConv, gotCont, reused := resolveSessionIDs(api, MapModel(model), req.Messages) + if !reused || gotConv != "conv-ctk-1" || gotCont != "cont-ctk-1" { + t.Fatalf("resolve = %s/%s reused=%v", gotConv, gotCont, reused) + } + // Brand-new chat must not reuse. + _, _, reusedNew := resolveSessionIDs(api, MapModel(model), []ClaudeMessage{{Role: "user", Content: "totally new chat opener"}}) + if reusedNew { + t.Fatal("new chat must not reuse") + } +} + +// Regression: the seal must fingerprint the CLIENT transcript, not the wire +// payload. ClaudeToKiro prepends a system-priming user/assistant pair whenever +// a system prompt exists (always true for Claude Code / Pi), so a +// payload-derived chain could never match the next request's lookup key and +// continuity silently never happened in production. +func TestSealClaudeSessionMatchesLookupWithSystemPrompt(t *testing.T) { + globalSessionStore = newSessionStore() + + const api = "key-sys" + const model = "claude-sonnet-4.5" + system := "you are a coding agent with a long system prompt" + + turn1 := []ClaudeMessage{{Role: "user", Content: "first question"}} + req1 := &ClaudeRequest{Model: model, System: system, Messages: turn1} + payload1 := ClaudeToKiro(req1, false) + + conv1, cont1, reused := resolveSessionIDs(api, MapModel(model), turn1) + if reused { + t.Fatalf("first turn must not reuse") + } + // Sanity: the payload really does carry the priming pair that used to break this. + if len(payload1.ConversationState.History) == 0 { + t.Fatalf("expected system priming history in payload") + } + + // Seal exactly as the handler does, from the inbound transcript. + sealClaudeSession(api, model, conv1, cont1, turn1, "first answer", "", nil, "thinking", false) + + turn2 := []ClaudeMessage{ + {Role: "user", Content: "first question"}, + {Role: "assistant", Content: "first answer"}, + {Role: "user", Content: "second question"}, + } + conv2, cont2, reused2 := resolveSessionIDs(api, MapModel(model), turn2) + if !reused2 { + t.Fatalf("continuation must reuse after a real seal (system prompt present)") + } + if conv2 != conv1 || cont2 != cont1 { + t.Fatalf("ids not reused: conv %q vs %q, cont %q vs %q", conv2, conv1, cont2, cont1) + } +} + +// Regression: a tool loop must keep the chain alive. The seal renders this +// turn's tool_use blocks the same way the client replays them. +func TestSealClaudeSessionSurvivesToolLoop(t *testing.T) { + globalSessionStore = newSessionStore() + + const api = "key-tool" + const model = "claude-sonnet-4.5" + + turn1 := []ClaudeMessage{{Role: "user", Content: "read the file"}} + conv, cont, _ := resolveSessionIDs(api, MapModel(model), turn1) + sealClaudeSession(api, model, conv, cont, turn1, "let me look", "", + []KiroToolUse{{ToolUseID: "tu_1", Name: "read_file", Input: map[string]interface{}{"path": "a.go"}}}, "thinking", false) + + // Client replays assistant text + tool_use, then sends the tool_result. + turn2 := []ClaudeMessage{ + {Role: "user", Content: "read the file"}, + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "let me look"}, + map[string]interface{}{"type": "tool_use", "id": "tu_1", "name": "read_file", "input": map[string]interface{}{"path": "a.go"}}, + }}, + {Role: "user", Content: []interface{}{ + map[string]interface{}{"type": "tool_result", "tool_use_id": "tu_1", "content": "file body"}, + }}, + } + got, gotCont, reused := resolveSessionIDs(api, MapModel(model), turn2) + if !reused { + t.Fatalf("tool-result turn must continue the chain") + } + if got != conv || gotCont != cont { + t.Fatalf("tool loop lost ids: %q/%q want %q/%q", got, gotCont, conv, cont) + } +} + +// Regression: with default "thinking" format the client stores a thinking +// content block. Seal must include that block or the next-turn lookup misses. +func TestSealClaudeSessionMatchesLookupWithThinkingBlock(t *testing.T) { + globalSessionStore = newSessionStore() + const api = "key-think" + const model = "claude-sonnet-4.5" + + turn1 := []ClaudeMessage{{Role: "user", Content: "first question"}} + conv, cont, _ := resolveSessionIDs(api, MapModel(model), turn1) + sealClaudeSession(api, model, conv, cont, turn1, "first answer", "let me reason", nil, "thinking", false) + + turn2 := []ClaudeMessage{ + {Role: "user", Content: "first question"}, + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{"type": "thinking", "thinking": "let me reason"}, + map[string]interface{}{"type": "text", "text": "first answer"}, + }}, + {Role: "user", Content: "second question"}, + } + got, gotCont, reused := resolveSessionIDs(api, MapModel(model), turn2) + if !reused { + t.Fatal("thinking-format continuation must reuse") + } + if got != conv || gotCont != cont { + t.Fatalf("ids lost under thinking format: %q/%q want %q/%q", got, gotCont, conv, cont) + } +} + +// Regression: "think" format folds reasoning into the text the client stores. +// Seal must fingerprint the folded text, not the bare answer. +func TestSealClaudeSessionMatchesLookupWithThinkTags(t *testing.T) { + globalSessionStore = newSessionStore() + const api = "key-think-tags" + const model = "claude-sonnet-4.5" + + turn1 := []ClaudeMessage{{Role: "user", Content: "first question"}} + conv, cont, _ := resolveSessionIDs(api, MapModel(model), turn1) + sealClaudeSession(api, model, conv, cont, turn1, "first answer", "let me reason", nil, "think", false) + + turn2 := []ClaudeMessage{ + {Role: "user", Content: "first question"}, + {Role: "assistant", Content: "let me reasonfirst answer"}, + {Role: "user", Content: "second question"}, + } + got, gotCont, reused := resolveSessionIDs(api, MapModel(model), turn2) + if !reused { + t.Fatal("think-tag format continuation must reuse") + } + if got != conv || gotCont != cont { + t.Fatalf("ids lost under think tags: %q/%q want %q/%q", got, gotCont, conv, cont) + } +} + +func TestSealClaudeSessionMatchesOmittedThinkingBlock(t *testing.T) { + globalSessionStore = newSessionStore() + inbound := []ClaudeMessage{{Role: "user", Content: "question"}} + sealClaudeSession("key-omit", "model", "conv-omit", "cont-omit", inbound, "answer", "hidden", nil, "thinking", true) + _, _, reused := resolveSessionIDs("key-omit", MapModel("model"), []ClaudeMessage{ + {Role: "user", Content: "question"}, + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{"type": "thinking", "thinking": ""}, + map[string]interface{}{"type": "text", "text": "answer"}, + }}, + {Role: "user", Content: "next"}, + }) + if !reused { + t.Fatal("empty client-visible thinking shell must remain in sealed shape") + } +} + +// Regression: seal text must be the VISIBLE answer, never the integrity-check +// padding that used to glue tool name+input onto rawContentBuilder. +func TestSealClaudeSessionDoesNotFingerprintToolPadding(t *testing.T) { + globalSessionStore = newSessionStore() + const api = "key-pad" + const model = "claude-sonnet-4.5" + + turn1 := []ClaudeMessage{{Role: "user", Content: "read the file"}} + conv, cont, _ := resolveSessionIDs(api, MapModel(model), turn1) + + // What the client actually stores. + clean := "let me look" + // What rawContentBuilder used to contain after OnToolUse padding. + polluted := clean + `read_file{"path":"a.go"}` + + // Seal the clean shape (as the fixed handler now does). + sealClaudeSession(api, model, conv, cont, turn1, clean, "", + []KiroToolUse{{ToolUseID: "tu_1", Name: "read_file"}}, "thinking", false) + + // Next request replays clean text + tool_use — must hit. + turn2 := []ClaudeMessage{ + {Role: "user", Content: "read the file"}, + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{"type": "text", "text": clean}, + map[string]interface{}{"type": "tool_use", "id": "tu_1", "name": "read_file"}, + }}, + {Role: "user", Content: []interface{}{ + map[string]interface{}{"type": "tool_result", "tool_use_id": "tu_1", "content": "body"}, + }}, + } + if _, _, reused := resolveSessionIDs(api, MapModel(model), turn2); !reused { + t.Fatal("clean seal must match clean client replay") + } + + // A polluted seal must NOT match the clean client replay (proves the bug + // class: if we still sealed polluted text, continuity would die here). + globalSessionStore = newSessionStore() + conv2, cont2, _ := resolveSessionIDs(api, MapModel(model), turn1) + sealClaudeSession(api, model, conv2, cont2, turn1, polluted, "", + []KiroToolUse{{ToolUseID: "tu_1", Name: "read_file"}}, "thinking", false) + if _, _, reused := resolveSessionIDs(api, MapModel(model), turn2); reused { + t.Fatal("polluted seal must NOT match clean client replay — that was the production bug") + } +} + +func TestSessionStoreCollisionFailsClosed(t *testing.T) { + s := newSessionStore() + s.put("key", "model", "same-chain", "conv-a", "cont-a") + s.put("key", "model", "same-chain", "conv-b", "cont-b") + if got := s.lookup("key", "model", "same-chain"); got != nil { + t.Fatalf("ambiguous transcript must not reuse either session: %+v", got) + } +} + +func TestSessionFingerprintIncludesToolInput(t *testing.T) { + chain := func(path string) []ClaudeMessage { + return []ClaudeMessage{ + {Role: "user", Content: "read a file"}, + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{ + "type": "tool_use", "id": "tool-1", "name": "read_file", + "input": map[string]interface{}{"path": path}, + }, + }}, + } + } + if chainFingerprintClaude(chain("a.go")) == chainFingerprintClaude(chain("b.go")) { + t.Fatal("different tool inputs must produce different chain fingerprints") + } +} + +func TestSessionFingerprintIncludesStructuredToolResult(t *testing.T) { + chain := func(result string) []ClaudeMessage { + return []ClaudeMessage{ + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{ + "type": "tool_result", "tool_use_id": "tool-1", + "content": []interface{}{map[string]interface{}{"type": "text", "text": result}}, + }, + }}, + } + } + if chainFingerprintClaude(chain("first")) == chainFingerprintClaude(chain("second")) { + t.Fatal("different structured tool results must produce different chain fingerprints") + } +} From f4982990e26852f51e361c3be672dc8fda9adc57 Mon Sep 17 00:00:00 2001 From: asuan-dev <116167305+asuan-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:16:24 +0800 Subject: [PATCH 5/6] feat(proxy): map Claude documents and centralize stop-reason classes Translate document/file blocks into Kiro documents (including raw text), validate MIME/magic/name/limit rules, and classify upstream stop reasons once for Claude, OpenAI, and Responses mappings. --- proxy/documents_test.go | 260 +++++++++++++++++++++++++++ proxy/translator.go | 371 ++++++++++++++++++++++++++++++++++++--- proxy/translator_test.go | 41 +++-- 3 files changed, 631 insertions(+), 41 deletions(-) create mode 100644 proxy/documents_test.go diff --git a/proxy/documents_test.go b/proxy/documents_test.go new file mode 100644 index 00000000..04cf2a30 --- /dev/null +++ b/proxy/documents_test.go @@ -0,0 +1,260 @@ +package proxy + +import ( + "encoding/base64" + "strings" + "testing" +) + +// Seam: extractClaudeUserContent must split images vs documents. +// PDF/base64 document blocks become KiroDocument; image/* stay images. +func TestExtractClaudeUserContentDocumentsVsImages(t *testing.T) { + pdfB64 := base64.StdEncoding.EncodeToString([]byte("%PDF-1.4 fake")) + imgB64 := base64.StdEncoding.EncodeToString([]byte{0x89, 0x50, 0x4e, 0x47}) // PNG magic-ish + + content := []interface{}{ + map[string]interface{}{"type": "text", "text": "see attached"}, + map[string]interface{}{ + "type": "document", + "source": map[string]interface{}{ + "type": "base64", + "media_type": "application/pdf", + "data": pdfB64, + }, + "title": "report.pdf", + }, + map[string]interface{}{ + "type": "image", + "source": map[string]interface{}{ + "type": "base64", + "media_type": "image/png", + "data": imgB64, + }, + }, + } + + text, images, docs, tools, errMsg := extractClaudeUserContent(content) + if errMsg != "" { + t.Fatalf("unexpected document error: %s", errMsg) + } + if text != "see attached" { + t.Fatalf("text=%q", text) + } + if len(tools) != 0 { + t.Fatalf("tools=%d", len(tools)) + } + if len(images) != 1 || images[0].Format != "png" { + t.Fatalf("images=%+v", images) + } + if len(docs) != 1 { + t.Fatalf("docs=%d %+v", len(docs), docs) + } + if docs[0].Format != "pdf" { + t.Fatalf("doc format=%q", docs[0].Format) + } + if docs[0].Name == "" { + t.Fatal("doc name empty") + } + if docs[0].Source.Bytes != pdfB64 { + t.Fatal("doc bytes mismatch") + } +} + +// Seam: ClaudeToKiro must put documents on current user message. +func TestClaudeToKiroWiresDocumentsOnCurrentMessage(t *testing.T) { + pdfB64 := base64.StdEncoding.EncodeToString([]byte("%PDF-1.4 x")) + req := &ClaudeRequest{ + Model: "claude-sonnet-4.5", + Messages: []ClaudeMessage{ + {Role: "user", Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "read this"}, + map[string]interface{}{ + "type": "document", + "source": map[string]interface{}{ + "type": "base64", + "media_type": "application/pdf", + "data": pdfB64, + }, + "title": "a.pdf", + }, + }}, + }, + } + payload := ClaudeToKiro(req, false) + docs := payload.ConversationState.CurrentMessage.UserInputMessage.Documents + if len(docs) != 1 || docs[0].Format != "pdf" { + t.Fatalf("current documents=%+v", docs) + } + if payload.ConversationState.CurrentMessage.UserInputMessage.Content == "" { + t.Fatal("content should remain non-empty with documents") + } +} + +// Unsupported document mime types are skipped, not turned into images. +func TestExtractClaudeUserContentSkipsUnknownDocumentMime(t *testing.T) { + b64 := base64.StdEncoding.EncodeToString([]byte("zzz")) + content := []interface{}{ + map[string]interface{}{ + "type": "document", + "source": map[string]interface{}{ + "media_type": "application/x-unknown", + "data": b64, + }, + "title": "x.bin", + }, + } + _, images, docs, _, errMsg := extractClaudeUserContent(content) + if errMsg != "" { + t.Fatalf("unknown mime must skip without error: %s", errMsg) + } + if len(docs) != 0 || len(images) != 0 { + t.Fatalf("unknown mime must skip: docs=%+v images=%+v", docs, images) + } +} + +func claudeDocumentBlock(name, mediaType string, data []byte) map[string]interface{} { + return map[string]interface{}{ + "type": "document", + "source": map[string]interface{}{ + "type": "base64", + "media_type": mediaType, + "data": base64.StdEncoding.EncodeToString(data), + }, + "title": name, + } +} + +func TestClaudeDocumentValidationRejectsBadBase64AndMagic(t *testing.T) { + for _, tc := range []struct { + name string + block map[string]interface{} + wantError string + }{ + { + name: "bad base64", + block: map[string]interface{}{ + "type": "document", + "source": map[string]interface{}{ + "media_type": "application/pdf", + "data": "AQI", + }, + "title": "bad.pdf", + }, + wantError: "base64", + }, + { + name: "bad magic", + block: claudeDocumentBlock("fake.pdf", "application/pdf", []byte("not a pdf")), + wantError: "does not match", + }, + } { + t.Run(tc.name, func(t *testing.T) { + req := &ClaudeRequest{Messages: []ClaudeMessage{{Role: "user", Content: []interface{}{tc.block}}}} + if got := validateClaudeRequestShape(req); !strings.Contains(strings.ToLower(got), tc.wantError) { + t.Fatalf("validation error = %q, want %q", got, tc.wantError) + } + }) + } +} + +func TestClaudeDocumentsSanitizeAndDeduplicateNames(t *testing.T) { + pdf := []byte("%PDF-1.7 valid") + content := []interface{}{ + claudeDocumentBlock("Quarter:Report.pdf", "application/pdf", pdf), + claudeDocumentBlock("Quarter?Report.pdf", "application/pdf", pdf), + } + _, _, docs, _, errMsg := extractClaudeUserContent(content) + if errMsg != "" { + t.Fatalf("unexpected document error: %s", errMsg) + } + if len(docs) != 1 { + t.Fatalf("normalized duplicate documents = %+v, want one", docs) + } + if docs[0].Name != "Quarter-Report" { + t.Fatalf("sanitized document name = %q", docs[0].Name) + } +} + +func TestClaudeDocumentsAcceptOfficeMagicBytes(t *testing.T) { + content := []interface{}{ + claudeDocumentBlock("report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte{0x50, 0x4b, 0x03, 0x04, 0x01}), + claudeDocumentBlock("legacy.xls", "application/vnd.ms-excel", []byte{0xd0, 0xcf, 0x11, 0xe0, 0x01}), + } + _, _, docs, _, errMsg := extractClaudeUserContent(content) + if errMsg != "" { + t.Fatalf("unexpected document error: %s", errMsg) + } + if len(docs) != 2 || docs[0].Format != "docx" || docs[1].Format != "xls" { + t.Fatalf("Office documents = %+v", docs) + } +} + +func TestClaudeDocumentLimitAcrossConversation(t *testing.T) { + pdf := []byte("%PDF-1.7 valid") + messages := make([]ClaudeMessage, 0, 6) + for i := 0; i < 6; i++ { + messages = append(messages, ClaudeMessage{Role: "user", Content: []interface{}{ + claudeDocumentBlock(string(rune('a'+i))+".pdf", "application/pdf", pdf), + }}) + } + got := validateClaudeRequestShape(&ClaudeRequest{Messages: messages}) + if !strings.Contains(strings.ToLower(got), "maximum is 5") { + t.Fatalf("document limit validation = %q", got) + } +} + +func TestClaudeToKiroDropsDuplicateDocumentsAcrossConversation(t *testing.T) { + pdf := []byte("%PDF-1.7 valid") + req := &ClaudeRequest{Model: "claude-sonnet-4.5", Messages: []ClaudeMessage{ + {Role: "user", Content: []interface{}{claudeDocumentBlock("same.pdf", "application/pdf", pdf)}}, + {Role: "assistant", Content: "seen"}, + {Role: "user", Content: []interface{}{claudeDocumentBlock("same.pdf", "application/pdf", pdf)}}, + }} + payload := ClaudeToKiro(req, false) + total := len(payload.ConversationState.CurrentMessage.UserInputMessage.Documents) + for _, message := range payload.ConversationState.History { + if message.UserInputMessage != nil { + total += len(message.UserInputMessage.Documents) + } + } + if total != 1 { + t.Fatalf("duplicate documents across conversation = %d, want one", total) + } +} + +func TestClaudeRequestWithOnlyDocumentHasUserContext(t *testing.T) { + req := &ClaudeRequest{Messages: []ClaudeMessage{{ + Role: "user", + Content: []interface{}{ + claudeDocumentBlock("only.pdf", "application/pdf", []byte("%PDF-1.7 valid")), + }, + }}} + if got := validateClaudeRequestShape(req); got != "" { + t.Fatalf("document-only request rejected: %s", got) + } +} + +func TestClaudeRawTextDocumentMapsToKiroTXT(t *testing.T) { + block := map[string]interface{}{ + "type": "document", + "source": map[string]interface{}{ + "type": "text", + "media_type": "text/plain", + "data": "hello world\nexact text", + }, + "title": "notes.txt", + } + req := &ClaudeRequest{Model: "claude-sonnet-4.5", Messages: []ClaudeMessage{{Role: "user", Content: []interface{}{block}}}} + if got := validateClaudeRequestShape(req); got != "" { + t.Fatalf("valid raw-text document rejected: %s", got) + } + payload := ClaudeToKiro(req, false) + documents := payload.ConversationState.CurrentMessage.UserInputMessage.Documents + if len(documents) != 1 || documents[0].Format != "txt" { + t.Fatalf("raw-text documents = %+v", documents) + } + raw, err := base64.StdEncoding.DecodeString(documents[0].Source.Bytes) + if err != nil || string(raw) != "hello world\nexact text" { + t.Fatalf("raw-text bytes = %q, err=%v", raw, err) + } +} diff --git a/proxy/translator.go b/proxy/translator.go index 0f77ddd9..7969a546 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -1,6 +1,7 @@ package proxy import ( + "bytes" "encoding/base64" "encoding/json" "fmt" @@ -212,18 +213,25 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { history := make([]KiroHistoryMessage, 0) var currentContent string var currentImages []KiroImage + var currentDocuments []KiroDocument var currentToolResults []KiroToolResult for i, msg := range req.Messages { isLast := i == len(req.Messages)-1 if msg.Role == "user" { - content, images, toolResults := extractClaudeUserContent(msg.Content) - content = normalizeUserContent(content, len(images) > 0) + content, images, documents, toolResults, docErr := extractClaudeUserContent(msg.Content) + if docErr != "" { + // Request-shape validation should have rejected this already. + // Keep translation defensive for direct callers. + continue + } + content = normalizeUserContent(content, len(images) > 0 || len(documents) > 0) if isLast { currentContent = content currentImages = images + currentDocuments = documents currentToolResults = toolResults } else { userMsg := KiroUserInputMessage{ @@ -234,6 +242,9 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { if len(images) > 0 { userMsg.Images = images } + if len(documents) > 0 { + userMsg.Documents = documents + } if len(toolResults) > 0 { userMsg.UserInputMessageContext = &UserInputMessageContext{ ToolResults: toolResults, @@ -245,12 +256,11 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { } } else if msg.Role == "assistant" { content, toolUses := extractClaudeAssistantContent(msg.Content) - history = append(history, KiroHistoryMessage{ - AssistantResponseMessage: &KiroAssistantResponseMessage{ - Content: content, - ToolUses: toolUses, - }, - }) + assistant := &KiroAssistantResponseMessage{ + Content: content, + ToolUses: toolUses, + } + history = append(history, KiroHistoryMessage{AssistantResponseMessage: assistant}) } } @@ -289,12 +299,13 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { } else { history = sanitizeKiroHistory(history, nil) } + currentDocuments = deduplicateConversationDocuments(history, currentDocuments) // 构建最终内容 finalContent := "" if currentContent != "" { finalContent = currentContent - } else if len(currentImages) > 0 { + } else if len(currentImages) > 0 || len(currentDocuments) > 0 { finalContent = normalizeUserContent("", true) } else if len(currentToolResults) > 0 { finalContent = buildToolResultsContinuation(currentToolResults) @@ -310,13 +321,16 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { payload.ToolNameMap = toolNameMap payload.ConversationState.ChatTriggerType = "MANUAL" payload.ConversationState.AgentTaskType = "vibe" + // Provisional IDs; handler overwrites via resolveSessionIDs(apiKey, ...) so + // multi-tenant isolation is not lost inside this pure translator. payload.ConversationState.AgentContinuationId = uuid.New().String() - payload.ConversationState.ConversationID = buildConversationID(modelID, systemPrompt, firstClaudeConversationAnchor(req.Messages)) + payload.ConversationState.ConversationID = uuid.New().String() payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{ - Content: finalContent, - ModelID: modelID, - Origin: origin, - Images: currentImages, + Content: finalContent, + ModelID: modelID, + Origin: origin, + Images: currentImages, + Documents: currentDocuments, } // Only attach structured tool results when they answer the last history @@ -622,13 +636,15 @@ func extractSystemPrompt(system interface{}) string { return "" } -func extractClaudeUserContent(content interface{}) (string, []KiroImage, []KiroToolResult) { +func extractClaudeUserContent(content interface{}) (string, []KiroImage, []KiroDocument, []KiroToolResult, string) { var text string var images []KiroImage + var documents []KiroDocument var toolResults []KiroToolResult + documentNames := make(map[string]bool) if s, ok := content.(string); ok { - return s, nil, nil + return s, nil, nil, nil, "" } // Accept both JSON-decoded []interface{} and in-memory []map[string]interface{} @@ -644,6 +660,15 @@ func extractClaudeUserContent(content interface{}) (string, []KiroImage, []KiroT if img := extractImageFromClaudeBlock(block); img != nil { images = append(images, *img) } + case "document", "file", "input_document": + doc, validationError := parseClaudeDocumentBlock(block) + if validationError != "" { + return "", nil, nil, nil, validationError + } + if doc != nil && !documentNames[doc.Name] { + documentNames[doc.Name] = true + documents = append(documents, *doc) + } case "tool_result": toolUseID, _ := block["tool_use_id"].(string) resultContent, resultImages := extractToolResultContent(block["content"]) @@ -661,7 +686,7 @@ func extractClaudeUserContent(content interface{}) (string, []KiroImage, []KiroT } } - return text, images, toolResults + return text, images, documents, toolResults, "" } // contentBlocksAsMaps normalizes Claude content arrays for extraction. @@ -685,6 +710,171 @@ func contentBlocksAsMaps(content interface{}) []map[string]interface{} { } } +const maxDocumentsPerConversation = 5 + +// documentMIMEToFormat mirrors the official client's supported document formats. +var documentMIMEToFormat = map[string]string{ + "application/pdf": "pdf", + "text/csv": "csv", + "application/msword": "doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", + "application/vnd.ms-excel": "xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "text/html": "html", + "text/plain": "txt", + "text/markdown": "md", +} + +var documentMagicBytes = map[string][]byte{ + "application/pdf": {0x25, 0x50, 0x44, 0x46}, + "application/msword": {0xd0, 0xcf, 0x11, 0xe0}, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": {0x50, 0x4b, 0x03, 0x04}, + "application/vnd.ms-excel": {0xd0, 0xcf, 0x11, 0xe0}, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {0x50, 0x4b, 0x03, 0x04}, +} + +var ( + documentBase64Pattern = regexp.MustCompile(`^[A-Za-z0-9+/]*={0,2}$`) + documentNameInvalid = regexp.MustCompile(`[^a-zA-Z0-9\s\-()\[\]]`) + documentRepeatedHyphens = regexp.MustCompile(`-{2,}`) +) + +func documentBlockName(block map[string]interface{}, format string) string { + name, _ := block["title"].(string) + if name == "" { + name, _ = block["name"].(string) + } + if name == "" { + name = "document." + format + } + return name +} + +func sanitizeDocumentName(name string) string { + if dot := strings.LastIndex(name, "."); dot >= 0 { + name = name[:dot] + } + name = documentNameInvalid.ReplaceAllString(name, "-") + name = documentRepeatedHyphens.ReplaceAllString(name, "-") + name = strings.Join(strings.Fields(name), " ") + runes := []rune(strings.TrimSpace(name)) + if len(runes) > 200 { + runes = runes[:200] + } + name = string(runes) + if name == "" { + return "document" + } + return name +} + +// parseClaudeDocumentBlock returns nil without error for unsupported or empty +// attachments, matching the official client. Supported malformed documents +// return a user-facing validation message. +func parseClaudeDocumentBlock(block map[string]interface{}) (*KiroDocument, string) { + source, _ := block["source"].(map[string]interface{}) + if source == nil { + return nil, "" + } + mediaType, _ := source["media_type"].(string) + if mediaType == "" { + mediaType, _ = source["mediaType"].(string) + } + if mediaType == "" { + mediaType, _ = source["mime_type"].(string) + } + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + format, ok := documentMIMEToFormat[mediaType] + if !ok { + return nil, "" + } + + sourceType, _ := source["type"].(string) + sourceType = strings.ToLower(strings.TrimSpace(sourceType)) + if sourceType != "" && sourceType != "base64" && sourceType != "text" { + return nil, "" + } + data, _ := source["data"].(string) + if data == "" { + return nil, "" + } + + var raw []byte + if sourceType == "text" { + if mediaType != "text/plain" { + return nil, "" + } + raw = []byte(data) + } else { + if strings.HasPrefix(data, "data:") { + idx := strings.Index(data, "base64,") + if idx < 0 { + return nil, fmt.Sprintf("invalid base64 for document %q", documentBlockName(block, format)) + } + data = data[idx+7:] + } + if len(data) == 0 || len(data)%4 != 0 || !documentBase64Pattern.MatchString(data) { + return nil, fmt.Sprintf("invalid base64 for document %q", documentBlockName(block, format)) + } + var err error + raw, err = base64.StdEncoding.DecodeString(data) + if err != nil || len(raw) == 0 { + return nil, fmt.Sprintf("invalid base64 for document %q", documentBlockName(block, format)) + } + if magic := documentMagicBytes[mediaType]; len(magic) > 0 && !bytes.HasPrefix(raw, magic) { + return nil, fmt.Sprintf("document %q content does not match declared type %s", documentBlockName(block, format), mediaType) + } + } + + doc := &KiroDocument{Name: sanitizeDocumentName(documentBlockName(block, format)), Format: format} + doc.Source.Bytes = base64.StdEncoding.EncodeToString(raw) + return doc, "" +} + +func validateClaudeDocuments(messages []ClaudeMessage) string { + seen := make(map[string]bool) + for _, message := range messages { + if !strings.EqualFold(strings.TrimSpace(message.Role), "user") { + continue + } + _, _, documents, _, validationError := extractClaudeUserContent(message.Content) + if validationError != "" { + return validationError + } + for _, doc := range documents { + if seen[doc.Name] { + continue + } + seen[doc.Name] = true + if len(seen) > maxDocumentsPerConversation { + return fmt.Sprintf("too many documents attached (%d); maximum is %d per conversation", len(seen), maxDocumentsPerConversation) + } + } + } + return "" +} + +func deduplicateConversationDocuments(history []KiroHistoryMessage, current []KiroDocument) []KiroDocument { + seen := make(map[string]bool) + filter := func(documents []KiroDocument) []KiroDocument { + filtered := documents[:0] + for _, document := range documents { + if document.Name == "" || seen[document.Name] || len(seen) >= maxDocumentsPerConversation { + continue + } + seen[document.Name] = true + filtered = append(filtered, document) + } + return filtered + } + for i := range history { + if history[i].UserInputMessage != nil { + history[i].UserInputMessage.Documents = filter(history[i].UserInputMessage.Documents) + } + } + return filter(current) +} + func extractImageFromClaudeBlock(block map[string]interface{}) *KiroImage { if source, ok := block["source"].(map[string]interface{}); ok { if data, ok := source["data"].(string); ok { @@ -759,6 +949,7 @@ func extractToolResultContent(content interface{}) (string, []KiroImage) { func extractClaudeAssistantContent(content interface{}) (string, []KiroToolUse) { var text string var toolUses []KiroToolUse + var pendingSearchQuery string if s, ok := content.(string); ok { return s, nil @@ -766,6 +957,9 @@ func extractClaudeAssistantContent(content interface{}) (string, []KiroToolUse) // Same dual-shape support as extractClaudeUserContent (JSON []interface{} // and in-memory []map[string]interface{} from agentic loop feedback). + // Native Anthropic server web_search blocks are not valid Kiro history tool + // uses; fold their results into assistant text so the next turn still sees + // the search evidence the client already displayed. for _, block := range contentBlocksAsMaps(content) { blockType, _ := block["type"].(string) switch blockType { @@ -785,12 +979,55 @@ func extractClaudeAssistantContent(content interface{}) (string, []KiroToolUse) Name: name, Input: input, }) + case "server_tool_use": + name, _ := block["name"].(string) + if name != webSearchToolName { + continue + } + if input, ok := block["input"].(map[string]interface{}); ok { + pendingSearchQuery = toolUseQuery(input) + } else { + pendingSearchQuery = "" + } + case "web_search_tool_result": + summary := summarizeClaudeWebSearchResult(pendingSearchQuery, block["content"]) + if summary != "" { + if text != "" && !strings.HasSuffix(text, "\n") { + text += "\n\n" + } + text += summary + } + pendingSearchQuery = "" } } return text, toolUses } +func summarizeClaudeWebSearchResult(query string, content interface{}) string { + results := &WebSearchResults{} + for _, block := range contentBlocksAsMaps(content) { + if typ, _ := block["type"].(string); typ != "" && typ != "web_search_result" { + continue + } + title, _ := block["title"].(string) + url, _ := block["url"].(string) + snippet, _ := block["encrypted_content"].(string) + if snippet == "" { + snippet, _ = block["snippet"].(string) + } + item := WebSearchResult{Title: title, URL: url} + if snippet != "" { + item.Snippet = &snippet + } + results.Results = append(results.Results, item) + } + // Empty result arrays are valid MCP successes; keep the same + // generateSearchSummary("No results found.") representation so a later + // client-tool continuation still retains that the search happened. + return generateSearchSummary(query, results) +} + func convertClaudeTools(tools []ClaudeTool) ([]KiroToolWrapper, map[string]string) { if len(tools) == 0 { return nil, nil @@ -995,7 +1232,85 @@ func shortenToolName(name string) string { // ==================== Kiro -> Claude 转换 ==================== -func KiroToClaudeResponse(content, thinkingContent string, includeEmptyThinkingBlock bool, toolUses []KiroToolUse, inputTokens, outputTokens int, model string) *ClaudeResponse { +func normalizeKiroStopReason(reason string) string { + reason = strings.ToLower(strings.TrimSpace(reason)) + return strings.NewReplacer("-", "_", " ", "_").Replace(reason) +} + +type kiroStopClass uint8 + +const ( + kiroStopComplete kiroStopClass = iota + kiroStopLength + kiroStopContextLimit + kiroStopFiltered + kiroStopSequence + kiroStopPaused +) + +func classifyKiroStopReason(reason string) kiroStopClass { + switch normalizeKiroStopReason(reason) { + case "max_tokens", "max_output_tokens", "length": + return kiroStopLength + case "model_context_window_exceeded", "context_window_exceeded": + return kiroStopContextLimit + case "refusal", "content_filter", "content_filtered", "guardrail_intervened": + return kiroStopFiltered + case "stop_sequence": + return kiroStopSequence + case "pause_turn": + return kiroStopPaused + default: + return kiroStopComplete + } +} + +func mapClaudeStopReason(reason string, toolCount int) string { + if toolCount > 0 { + return "tool_use" + } + switch classifyKiroStopReason(reason) { + case kiroStopLength: + return "max_tokens" + case kiroStopContextLimit: + return "model_context_window_exceeded" + case kiroStopFiltered: + return "refusal" + case kiroStopSequence: + return "stop_sequence" + case kiroStopPaused: + return "pause_turn" + default: + return "end_turn" + } +} + +func mapOpenAIFinishReason(reason string, toolCount int) string { + if toolCount > 0 { + return "tool_calls" + } + switch classifyKiroStopReason(reason) { + case kiroStopLength, kiroStopContextLimit: + return "length" + case kiroStopFiltered: + return "content_filter" + default: + return "stop" + } +} + +func mapResponsesCompletion(reason string) (status, incompleteReason string) { + switch classifyKiroStopReason(reason) { + case kiroStopLength, kiroStopContextLimit: + return "incomplete", "max_output_tokens" + case kiroStopFiltered: + return "incomplete", "content_filter" + default: + return "completed", "" + } +} + +func KiroToClaudeResponse(content, thinkingContent string, includeEmptyThinkingBlock bool, toolUses []KiroToolUse, inputTokens, outputTokens int, model, stopReason string) *ClaudeResponse { blocks := make([]ClaudeContentBlock, 0) if thinkingContent != "" || includeEmptyThinkingBlock { @@ -1021,10 +1336,7 @@ func KiroToClaudeResponse(content, thinkingContent string, includeEmptyThinkingB }) } - stopReason := "end_turn" - if len(toolUses) > 0 { - stopReason = "tool_use" - } + clientStopReason := mapClaudeStopReason(stopReason, len(toolUses)) return &ClaudeResponse{ ID: "msg_" + uuid.New().String(), @@ -1032,7 +1344,7 @@ func KiroToClaudeResponse(content, thinkingContent string, includeEmptyThinkingB Role: "assistant", Content: blocks, Model: model, - StopReason: stopReason, + StopReason: clientStopReason, Usage: ClaudeUsage{ InputTokens: inputTokens, OutputTokens: outputTokens, @@ -1314,6 +1626,7 @@ func OpenAIToKiro(req *OpenAIRequest, thinking bool) *KiroPayload { // 构建 payload payload := &KiroPayload{} payload.ConversationState.ChatTriggerType = "MANUAL" + // The OpenAI path has no chain seal yet, so it keeps the content-derived id. payload.ConversationState.ConversationID = buildConversationID(modelID, systemPrompt, firstOpenAIConversationAnchor(nonSystemMessages)) payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{ Content: finalContent, @@ -1635,7 +1948,7 @@ func sanitizeKiroHistory(history []KiroHistoryMessage, currentToolResultIDs map[ // placeholder like ".": replayed across a long history that produces // dozens of "." assistant turns, which the model then imitates by // replying ".". Mark such turns for removal instead. - if msg.UserInputMessage != nil && strings.TrimSpace(msg.UserInputMessage.Content) == "" && len(msg.UserInputMessage.Images) == 0 { + if msg.UserInputMessage != nil && strings.TrimSpace(msg.UserInputMessage.Content) == "" && len(msg.UserInputMessage.Images) == 0 && len(msg.UserInputMessage.Documents) == 0 { msg.UserInputMessage.Content = minimalFallbackUserContent } } @@ -1665,7 +1978,8 @@ func sanitizeKiroHistory(history []KiroHistoryMessage, currentToolResultIDs map[ if last.UserInputMessage != nil && strings.TrimSpace(last.UserInputMessage.Content) == strings.TrimSpace(msg.UserInputMessage.Content) && strings.TrimSpace(msg.UserInputMessage.Content) != "" && - len(msg.UserInputMessage.Images) == 0 { + len(last.UserInputMessage.Images) == 0 && len(msg.UserInputMessage.Images) == 0 && + len(last.UserInputMessage.Documents) == 0 && len(msg.UserInputMessage.Documents) == 0 { continue // skip duplicate consecutive user turn } } @@ -1855,7 +2169,7 @@ func firstClaudeConversationAnchor(messages []ClaudeMessage) string { if msg.Role != "user" { continue } - text, _, toolResults := extractClaudeUserContent(msg.Content) + text, _, _, toolResults, _ := extractClaudeUserContent(msg.Content) if strings.TrimSpace(text) != "" { return strings.TrimSpace(text) } @@ -2157,8 +2471,8 @@ func extractThinkingFromContent(content string) (string, string) { } // KiroToOpenAIResponseWithReasoning 带 reasoning_content 的 OpenAI 响应 -func KiroToOpenAIResponseWithReasoning(content, reasoningContent string, toolUses []KiroToolUse, inputTokens, outputTokens int, model, thinkingFormat string) map[string]interface{} { - finishReason := "stop" +func KiroToOpenAIResponseWithReasoning(content, reasoningContent string, toolUses []KiroToolUse, inputTokens, outputTokens int, model, thinkingFormat, stopReason string) map[string]interface{} { + finishReason := mapOpenAIFinishReason(stopReason, len(toolUses)) message := map[string]interface{}{ "role": "assistant", @@ -2179,7 +2493,6 @@ func KiroToOpenAIResponseWithReasoning(content, reasoningContent string, toolUse } } message["tool_calls"] = toolCalls - finishReason = "tool_calls" } else { // 根据配置格式化 thinking 输出 if reasoningContent != "" { diff --git a/proxy/translator_test.go b/proxy/translator_test.go index ad6ef174..fef77374 100644 --- a/proxy/translator_test.go +++ b/proxy/translator_test.go @@ -203,6 +203,8 @@ func TestOpenAIConversationIDStableFromAnchor(t *testing.T) { } func TestClaudeConversationIDStableFromAnchor(t *testing.T) { + // Continuity requires a successful seal of turn 1; bare content hash is no + // longer used (it collided across chats that shared an opener). reqA := &ClaudeRequest{ Model: "claude-sonnet-4.5", System: "sys", @@ -210,6 +212,18 @@ func TestClaudeConversationIDStableFromAnchor(t *testing.T) { {Role: "user", Content: "hello"}, }, } + payloadA := ClaudeToKiro(reqA, false) + if payloadA.ConversationState.ConversationID == "" { + t.Fatal("expected non-empty conversation ID") + } + sealSessionAfterSuccess("", MapModel("claude-sonnet-4.5"), + payloadA.ConversationState.ConversationID, + payloadA.ConversationState.AgentContinuationId, + []ClaudeMessage{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "ok"}, + }) + reqB := &ClaudeRequest{ Model: "claude-sonnet-4.5", System: "sys", @@ -219,15 +233,12 @@ func TestClaudeConversationIDStableFromAnchor(t *testing.T) { {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") + gotConv, gotCont, reused := resolveSessionIDs("", MapModel("claude-sonnet-4.5"), reqB.Messages) + if !reused { + t.Fatal("expected reuse after seal") } - 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) + if gotConv != payloadA.ConversationState.ConversationID || gotCont != payloadA.ConversationState.AgentContinuationId { + t.Fatalf("stable ids after seal: got %s/%s want %s/%s", gotConv, gotCont, payloadA.ConversationState.ConversationID, payloadA.ConversationState.AgentContinuationId) } } @@ -268,7 +279,7 @@ func TestClaudeToKiroDropsLeadingAssistantHistory(t *testing.T) { } func TestKiroToClaudeResponseCanEmitEmptyThinkingBlock(t *testing.T) { - resp := KiroToClaudeResponse("final answer", "", true, nil, 10, 20, "claude-sonnet-4.6") + resp := KiroToClaudeResponse("final answer", "", true, nil, 10, 20, "claude-sonnet-4.6", "END_TURN") if len(resp.Content) != 2 { t.Fatalf("expected empty thinking block plus text block, got %d blocks", len(resp.Content)) @@ -526,6 +537,13 @@ func TestClaudeToolResultMixedTextAndImage(t *testing.T) { req := &ClaudeRequest{ Model: "claude-opus-4.8", Messages: []ClaudeMessage{ + {Role: "user", Content: "read this image"}, + { + Role: "assistant", + Content: []interface{}{ + map[string]interface{}{"type": "tool_use", "id": "tool_2", "name": "read", "input": map[string]interface{}{"path": "a.png"}}, + }, + }, { Role: "user", Content: []interface{}{ @@ -626,13 +644,12 @@ func TestOpenAIToolResultImageCarriedWhenFollowedByUser(t *testing.T) { var toolHistImages int for _, h := range payload.ConversationState.History { - if h.UserInputMessage != nil && h.UserInputMessage.UserInputMessageContext != nil && - len(h.UserInputMessage.UserInputMessageContext.ToolResults) > 0 { + if h.UserInputMessage != nil && strings.Contains(h.UserInputMessage.Content, toolResultsContinuationPrefix) { toolHistImages += len(h.UserInputMessage.Images) } } if toolHistImages != 1 { - t.Fatalf("expected tool image carried on the flushed tool-result history entry, got %d", toolHistImages) + t.Fatalf("expected tool image carried on the narrated tool-result history entry, got %d", toolHistImages) } cur := payload.ConversationState.CurrentMessage.UserInputMessage From b784418a301b5cf953055c6a7d5c56d615a67a28 Mon Sep 17 00:00:00 2001 From: asuan-dev <116167305+asuan-dev@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:16:25 +0800 Subject: [PATCH 6/6] fix(proxy): wire integrity, continuity, and web-search seals on author paths Route Claude/OpenAI/Responses through integrity retry, keep ClaudeToKiro pure with handler-side attachStoredReasoning, and restore web-search lifecycle seals: intermediate rounds seal the raw upstream tool shape so the next loop reuses ids; final flush seals client-visible content. --- proxy/handler.go | 498 ++++++++++++++++++++------------ proxy/handler_test.go | 214 +++++++++++++- proxy/responses_handler.go | 299 +++++++++++-------- proxy/responses_handler_test.go | 110 +++++++ proxy/responses_types.go | 33 ++- proxy/websearch.go | 2 +- proxy/websearch_loop.go | 155 ++++++---- proxy/websearch_test.go | 241 +++++++++++++++- 8 files changed, 1177 insertions(+), 375 deletions(-) diff --git a/proxy/handler.go b/proxy/handler.go index dfec8535..4def3f3b 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -135,6 +135,9 @@ func validateClaudeRequestShape(req *ClaudeRequest) string { if len(req.Messages) == 0 { return "messages must not be empty" } + if msg := validateClaudeDocuments(req.Messages); msg != "" { + return msg + } if msg := validateClaudeThinkingConfig(req.Thinking, req.MaxTokens); msg != "" { return msg } @@ -151,8 +154,8 @@ func validateClaudeRequestShape(req *ClaudeRequest) string { continue } - text, images, toolResults := extractClaudeUserContent(msg.Content) - if normalizeUserContent(text, len(images) > 0) != "" || len(toolResults) > 0 { + text, images, documents, toolResults, _ := extractClaudeUserContent(msg.Content) + if normalizeUserContent(text, len(images) > 0 || len(documents) > 0) != "" || len(toolResults) > 0 { hasUserContext = true } } @@ -876,33 +879,37 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re apiKeyID := apiKeyIDFromContext(r.Context()) - // Pure native web_search: relay via Kiro MCP (generateAssistantResponse does not run it). + // Pure native web_search is handled by Kiro MCP because + // generateAssistantResponse does not execute it. if hasWebSearchTool(&req) { h.handleWebSearchRequest(w, &req, estimatedInputTokens, apiKeyID) return } - // Mixed tools including native web_search: agentic loop digests web_search internally - // and returns client tool_use blocks as-is. + // Mixed native web_search and client tools use the existing local agentic + // loop; client tools remain visible to the caller. if hasWebSearchAmongTools(&req) { logger.Infof("[WebSearch] Mixed tools with native web_search, entering agentic loop") h.runWebSearchLoop(w, &req, thinking, estimatedInputTokens, apiKeyID) return } - // 转换请求 + // 转换请求(纯映射);会话 ID / 思考戳在 handler 侧按 apiKey 绑定。 kiroPayload := ClaudeToKiro(&req, thinking) - - // Stream or non-stream + modelID := MapModel(req.Model) + convID, contID, _ := resolveSessionIDs(apiKeyID, modelID, req.Messages) + kiroPayload.ConversationState.ConversationID = convID + kiroPayload.ConversationState.AgentContinuationId = contID + attachStoredReasoning(kiroPayload.ConversationState.History, apiKeyID, modelID, convID) if req.Stream { - h.handleClaudeStream(w, kiroPayload, req.Model, thinking, thinkingResponseOpts, estimatedInputTokens, cacheProfile, apiKeyID) + h.handleClaudeStream(w, kiroPayload, req.Model, thinking, thinkingResponseOpts, estimatedInputTokens, cacheProfile, apiKeyID, req.Messages) } else { - h.handleClaudeNonStream(w, kiroPayload, req.Model, thinking, thinkingResponseOpts, estimatedInputTokens, cacheProfile, apiKeyID) + h.handleClaudeNonStream(w, kiroPayload, req.Model, thinking, thinkingResponseOpts, estimatedInputTokens, cacheProfile, apiKeyID, req.Messages) } } // handleClaudeStream Claude 流式响应 -func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload, model string, thinking bool, thinkingOpts claudeThinkingResponseOptions, estimatedInputTokens int, cacheProfile *promptCacheProfile, apiKeyID string) { +func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload, model string, thinking bool, thinkingOpts claudeThinkingResponseOptions, estimatedInputTokens int, cacheProfile *promptCacheProfile, apiKeyID string, inbound []ClaudeMessage) { w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") @@ -1209,73 +1216,113 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload } } - callback := &KiroStreamCallback{ - OnText: func(text string, isThinking bool) { - if text == "" { - return - } - if isThinking { - rawThinkingBuilder.WriteString(text) - } else { - rawContentBuilder.WriteString(text) - } - processClaudeText(text, isThinking, false) - }, - OnToolUse: func(tu KiroToolUse) { - processClaudeText("", false, true) - rawContentBuilder.WriteString(tu.Name) - if b, err := json.Marshal(tu.Input); err == nil { - rawContentBuilder.Write(b) - } + var integrity streamIntegrityState + var stamp reasoningCapture - toolUses = append(toolUses, tu) - ensureMessageStart() - closeActiveBlock() + err := runKiroWithIntegrityRetry(account, payload, + &integrity, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnText: func(text string, isThinking bool) { + if text == "" { + return + } + integrity.observeText(text, isThinking) + if isThinking { + rawThinkingBuilder.WriteString(text) + } else { + rawContentBuilder.WriteString(text) + } + processClaudeText(text, isThinking, false) + }, + OnReasoningMeta: stamp.meta(), + OnToolUse: func(tu KiroToolUse) { + processClaudeText("", false, true) + // Do NOT append tool name/input into rawContentBuilder. + // That padding was only for integrity length, but toolCount is + // measured separately; padding polluted the sealed assistant + // text so the next turn's fingerprint never matched. + toolUses = append(toolUses, tu) + integrity.observeToolUse() + ensureMessageStart() + closeActiveBlock() - idx := nextContentIndex - nextContentIndex++ + idx := nextContentIndex + nextContentIndex++ - h.sendSSE(w, flusher, "content_block_start", map[string]interface{}{ - "type": "content_block_start", - "index": idx, - "content_block": map[string]interface{}{ - "type": "tool_use", - "id": tu.ToolUseID, - "name": tu.Name, - "input": map[string]interface{}{}, - }, - }) + h.sendSSE(w, flusher, "content_block_start", map[string]interface{}{ + "type": "content_block_start", + "index": idx, + "content_block": map[string]interface{}{ + "type": "tool_use", + "id": tu.ToolUseID, + "name": tu.Name, + "input": map[string]interface{}{}, + }, + }) - inputJSON, _ := json.Marshal(tu.Input) - h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ - "type": "content_block_delta", - "index": idx, - "delta": map[string]interface{}{ - "type": "input_json_delta", - "partial_json": string(inputJSON), - }, - }) + inputJSON, _ := json.Marshal(tu.Input) + h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ + "type": "content_block_delta", + "index": idx, + "delta": map[string]interface{}{ + "type": "input_json_delta", + "partial_json": string(inputJSON), + }, + }) - h.sendSSE(w, flusher, "content_block_stop", map[string]interface{}{ - "type": "content_block_stop", - "index": idx, - }) - }, - OnComplete: func(inTok, outTok int) { - inputTokens = inTok - outputTokens = outTok - }, - OnCredits: func(c float64) { - credits = c + h.sendSSE(w, flusher, "content_block_stop", map[string]interface{}{ + "type": "content_block_stop", + "index": idx, + }) + }, + OnComplete: func(inTok, outTok int) { + inputTokens = inTok + outputTokens = outTok + }, + OnCredits: func(c float64) { + credits = c + }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, + OnStopReason: func(reason string) { integrity.observeStopReason(reason) }, + } }, - OnContextUsage: func(pct float64) { - realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + func() { + rawContentBuilder.Reset() + rawThinkingBuilder.Reset() + toolUses = nil + inputTokens, outputTokens, credits, realInputTokens = 0, 0, 0, 0 + stamp.reset() + nextContentIndex = 0 + textBuffer = "" + inThinkingBlock = false + dropTagThinking = false + thinkingSource = thinkingSourceUnknown + thinkingStarted = false + eventThinkingOpen = false + activeBlockIndex = -1 + activeBlockType = "" }, - } - - err := CallKiroAPI(account, payload, callback) + func() bool { return !messageStarted }, + ) if err != nil { lastErr = err + if isStreamIntegrityError(err) { + if !messageStarted { + // Still safe to rotate: nothing flushed yet. + excluded[account.ID] = true + continue + } + // Partial content already reached the client — do not forge end_turn. + h.recordFailureWithDetails("claude", model, account.ID, err) + h.sendSSE(w, flusher, "error", map[string]interface{}{ + "type": "error", + "error": map[string]string{"type": "api_error", "message": err.Error()}, + }) + return + } excluded[account.ID] = true h.handleAccountFailure(account, err) if !messageStarted { @@ -1301,10 +1348,11 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload inputTokens = estimatedInputTokens } outputContent, extractedReasoning := extractThinkingFromContent(rawContentBuilder.String()) - thinkingOutput := rawThinkingBuilder.String() - if thinking && thinkingOutput == "" && extractedReasoning != "" { - thinkingOutput = extractedReasoning + rawThinkingForSeal := rawThinkingBuilder.String() + if rawThinkingForSeal == "" && extractedReasoning != "" { + rawThinkingForSeal = extractedReasoning } + thinkingOutput := rawThinkingForSeal if !thinking { thinkingOutput = "" } @@ -1316,16 +1364,21 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload h.promptCache.Update(account.ID, cacheProfile) h.recordSuccessLog("claude", model, account.ID, inputTokens+outputTokens, credits, time.Since(reqStart).Milliseconds()) - stopReason := "end_turn" - if len(toolUses) > 0 { - stopReason = "tool_use" + // Seal thinking stamp for this turn only after a successful complete + // response. Keyed by parent user text + assistant text so the next + // request can reattach without trusting the client to echo signature. + if isSuccessfulKiroTurn(integrity.StopReason, len(toolUses)) { + stamp.sealIfPresent(apiKeyID, MapModel(model), payload.ConversationState.ConversationID, currentTurnParentUser(payload), outputContent, toolUses, thinkingOutput) + sealClaudeSession(apiKeyID, model, payload.ConversationState.ConversationID, payload.ConversationState.AgentContinuationId, inbound, outputContent, thinkingOutput, toolUses, thinkingFormat, thinkingOpts.OmitDisplay) } + clientStopReason := mapClaudeStopReason(integrity.StopReason, len(toolUses)) + ensureMessageStart() h.sendSSE(w, flusher, "message_delta", map[string]interface{}{ "type": "message_delta", "delta": map[string]interface{}{ - "stop_reason": stopReason, + "stop_reason": clientStopReason, }, "usage": buildClaudeUsageMap(inputTokens, outputTokens, cacheUsage, cacheProfile != nil), }) @@ -1499,7 +1552,7 @@ func (h *Handler) getRequestLogs() []RequestLog { } // handleClaudeNonStream Claude 非流式响应 -func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayload, model string, thinking bool, thinkingOpts claudeThinkingResponseOptions, estimatedInputTokens int, cacheProfile *promptCacheProfile, apiKeyID string) { +func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayload, model string, thinking bool, thinkingOpts claudeThinkingResponseOptions, estimatedInputTokens int, cacheProfile *promptCacheProfile, apiKeyID string, inbound []ClaudeMessage) { excluded := make(map[string]bool) var lastErr error reqStart := time.Now() @@ -1524,43 +1577,63 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayl var credits float64 var realInputTokens int - callback := &KiroStreamCallback{ - OnText: func(text string, isThinking bool) { - if isThinking { - thinkingContent += text - } else { - content += text + var integrity streamIntegrityState + var stamp reasoningCapture + + err := runKiroWithIntegrityRetry(account, payload, + &integrity, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnText: func(text string, isThinking bool) { + integrity.observeText(text, isThinking) + if isThinking { + thinkingContent += text + } else { + content += text + } + }, + OnReasoningMeta: stamp.meta(), + OnToolUse: func(tu KiroToolUse) { + toolUses = append(toolUses, tu) + integrity.observeToolUse() + }, + OnComplete: func(inTok, outTok int) { + inputTokens = inTok + outputTokens = outTok + }, + OnCredits: func(c float64) { + credits = c + }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, + OnStopReason: func(reason string) { integrity.observeStopReason(reason) }, } }, - OnToolUse: func(tu KiroToolUse) { - toolUses = append(toolUses, tu) - }, - OnComplete: func(inTok, outTok int) { - inputTokens = inTok - outputTokens = outTok - }, - OnCredits: func(c float64) { - credits = c + func() { + content, thinkingContent = "", "" + toolUses = nil + inputTokens, outputTokens, credits, realInputTokens = 0, 0, 0, 0 + stamp.reset() }, - OnContextUsage: func(pct float64) { - realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) - }, - } - - err := CallKiroAPI(account, payload, callback) + nil, + ) if err != nil { lastErr = err excluded[account.ID] = true - h.handleAccountFailure(account, err) + if !isStreamIntegrityError(err) { + h.handleAccountFailure(account, err) + } continue } thinkingFormat := thinkingOpts.Format finalContent, extractedReasoning := extractThinkingFromContent(content) - rawThinkingContent := thinkingContent - if thinking && rawThinkingContent == "" && extractedReasoning != "" { - rawThinkingContent = extractedReasoning + rawThinkingForSeal := thinkingContent + if rawThinkingForSeal == "" && extractedReasoning != "" { + rawThinkingForSeal = extractedReasoning } + rawThinkingContent := rawThinkingForSeal if !thinking { rawThinkingContent = "" } @@ -1578,6 +1651,14 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayl h.promptCache.Update(account.ID, cacheProfile) h.recordSuccessLog("claude", model, account.ID, inputTokens+outputTokens, credits, time.Since(reqStart).Milliseconds()) + if isSuccessfulKiroTurn(integrity.StopReason, len(toolUses)) { + stamp.sealIfPresent(apiKeyID, MapModel(model), payload.ConversationState.ConversationID, currentTurnParentUser(payload), finalContent, toolUses, rawThinkingContent) + // Seal BEFORE folding reasoning into text for response formatting, but + // pass the raw thinking + format so claudeAssistantTurn can render the + // same shape the client will replay. + sealClaudeSession(apiKeyID, model, payload.ConversationState.ConversationID, payload.ConversationState.AgentContinuationId, inbound, finalContent, rawThinkingContent, toolUses, thinkingFormat, thinkingOpts.OmitDisplay) + } + responseThinkingContent := rawThinkingContent includeEmptyThinkingBlock := thinking && thinkingOpts.OmitDisplay && rawThinkingContent != "" if includeEmptyThinkingBlock { @@ -1596,7 +1677,7 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayl } } - resp := KiroToClaudeResponse(finalContent, responseThinkingContent, includeEmptyThinkingBlock, toolUses, inputTokens, outputTokens, model) + resp := KiroToClaudeResponse(finalContent, responseThinkingContent, includeEmptyThinkingBlock, toolUses, inputTokens, outputTokens, model, integrity.StopReason) resp.Usage.InputTokens = billedClaudeInputTokens(inputTokens, cacheUsage) resp.Usage.CacheCreationInputTokens = cacheUsage.CacheCreationInputTokens resp.Usage.CacheReadInputTokens = cacheUsage.CacheReadInputTokens @@ -1922,76 +2003,119 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload } } - callback := &KiroStreamCallback{ - OnText: func(text string, isThinking bool) { - if text == "" { - return - } - if isThinking { - rawReasoningBuilder.WriteString(text) - } else { - rawContentBuilder.WriteString(text) - } - processText(text, isThinking, false) - }, - OnToolUse: func(tu KiroToolUse) { - processText("", false, true) - - args, _ := json.Marshal(tu.Input) - rawContentBuilder.WriteString(tu.Name) - rawContentBuilder.Write(args) - tc := ToolCall{ID: tu.ToolUseID, Type: "function"} - tc.Function.Name = tu.Name - tc.Function.Arguments = string(args) - toolCalls = append(toolCalls, tc) - - chunk := map[string]interface{}{ - "id": chatID, - "object": "chat.completion.chunk", - "created": time.Now().Unix(), - "model": model, - "choices": []map[string]interface{}{{ - "index": 0, - "delta": map[string]interface{}{ - "tool_calls": []map[string]interface{}{{ - "index": toolCallIndex, - "id": tu.ToolUseID, - "type": "function", - "function": map[string]string{ - "name": tu.Name, - "arguments": string(args), + sendStreamError := func(err error) { + payload := map[string]interface{}{ + "error": map[string]string{ + "message": err.Error(), + "type": "server_error", + }, + } + data, _ := json.Marshal(payload) + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + + var integrity streamIntegrityState + + err := runKiroWithIntegrityRetry(account, payload, + &integrity, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnText: func(text string, isThinking bool) { + if text == "" { + return + } + integrity.observeText(text, isThinking) + if isThinking { + rawReasoningBuilder.WriteString(text) + } else { + rawContentBuilder.WriteString(text) + } + processText(text, isThinking, false) + }, + OnToolUse: func(tu KiroToolUse) { + processText("", false, true) + + args, _ := json.Marshal(tu.Input) + tc := ToolCall{ID: tu.ToolUseID, Type: "function"} + tc.Function.Name = tu.Name + tc.Function.Arguments = string(args) + toolCalls = append(toolCalls, tc) + integrity.observeToolUse() + + chunk := map[string]interface{}{ + "id": chatID, + "object": "chat.completion.chunk", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{{ + "index": 0, + "delta": map[string]interface{}{ + "tool_calls": []map[string]interface{}{{ + "index": toolCallIndex, + "id": tu.ToolUseID, + "type": "function", + "function": map[string]string{ + "name": tu.Name, + "arguments": string(args), + }, + }}, }, + "finish_reason": nil, }}, - }, - "finish_reason": nil, - }}, + } + toolCallIndex++ + data, _ := json.Marshal(chunk) + fmt.Fprintf(w, "data: %s\n\n", string(data)) + flusher.Flush() + responseStarted = true + }, + OnComplete: func(inTok, outTok int) { + inputTokens = inTok + outputTokens = outTok + }, + OnCredits: func(c float64) { + credits = c + }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, + OnStopReason: func(reason string) { integrity.observeStopReason(reason) }, } - toolCallIndex++ - data, _ := json.Marshal(chunk) - fmt.Fprintf(w, "data: %s\n\n", string(data)) - flusher.Flush() - responseStarted = true }, - OnComplete: func(inTok, outTok int) { - inputTokens = inTok - outputTokens = outTok - }, - OnCredits: func(c float64) { - credits = c - }, - OnContextUsage: func(pct float64) { - realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + func() { + rawContentBuilder.Reset() + rawReasoningBuilder.Reset() + toolCalls = nil + inputTokens, outputTokens, credits, realInputTokens = 0, 0, 0, 0 + toolCallIndex = 0 + textBuffer = "" + inThinkingBlock = false + dropTagThinking = false + thinkingSource = thinkingSourceUnknown + thinkingStarted = false + eventThinkingOpen = false }, - } - - err := CallKiroAPI(account, payload, callback) + func() bool { return !responseStarted }, + ) if err != nil { lastErr = err + if isStreamIntegrityError(err) { + if !responseStarted { + excluded[account.ID] = true + continue + } + // Partial content already reached the client — signal failure, no success terminator. + sendStreamError(err) + h.recordFailureWithDetails("openai", model, account.ID, err) + return + } excluded[account.ID] = true h.handleAccountFailure(account, err) if !responseStarted { continue } + sendStreamError(err) h.recordFailureWithDetails("openai", model, account.ID, err) return } @@ -2025,10 +2149,7 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) h.recordSuccessLog("openai", model, account.ID, inputTokens+outputTokens, credits, time.Since(reqStart).Milliseconds()) - finishReason := "stop" - if len(toolCalls) > 0 { - finishReason = "tool_calls" - } + finishReason := mapOpenAIFinishReason(integrity.StopReason, len(toolCalls)) chunk := map[string]interface{}{ "id": chatID, @@ -2087,27 +2208,42 @@ func (h *Handler) handleOpenAINonStream(w http.ResponseWriter, payload *KiroPayl var credits float64 var realInputTokens int - callback := &KiroStreamCallback{ - OnText: func(text string, isThinking bool) { - if isThinking { - reasoningContent += text - } else { - content += text + var integrity streamIntegrityState + + err := runKiroWithIntegrityRetry(account, payload, + &integrity, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnText: func(text string, isThinking bool) { + integrity.observeText(text, isThinking) + if isThinking { + reasoningContent += text + } else { + content += text + } + }, + OnToolUse: func(tu KiroToolUse) { integrity.observeToolUse(); toolUses = append(toolUses, tu) }, + OnComplete: func(inTok, outTok int) { inputTokens = inTok; outputTokens = outTok }, + OnCredits: func(c float64) { credits = c }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, + OnStopReason: func(reason string) { integrity.observeStopReason(reason) }, } }, - OnToolUse: func(tu KiroToolUse) { toolUses = append(toolUses, tu) }, - OnComplete: func(inTok, outTok int) { inputTokens = inTok; outputTokens = outTok }, - OnCredits: func(c float64) { credits = c }, - OnContextUsage: func(pct float64) { - realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + func() { + content, reasoningContent = "", "" + toolUses = nil + inputTokens, outputTokens, credits, realInputTokens = 0, 0, 0, 0 }, - } - - err := CallKiroAPI(account, payload, callback) + nil, + ) if err != nil { lastErr = err excluded[account.ID] = true - h.handleAccountFailure(account, err) + if !isStreamIntegrityError(err) { + h.handleAccountFailure(account, err) + } continue } @@ -2131,7 +2267,7 @@ func (h *Handler) handleOpenAINonStream(w http.ResponseWriter, payload *KiroPayl h.recordSuccessLog("openai", model, account.ID, inputTokens+outputTokens, credits, time.Since(reqStart).Milliseconds()) thinkingFormat := config.GetThinkingConfig().OpenAIFormat - resp := KiroToOpenAIResponseWithReasoning(finalContent, reasoningContent, toolUses, inputTokens, outputTokens, model, thinkingFormat) + resp := KiroToOpenAIResponseWithReasoning(finalContent, reasoningContent, toolUses, inputTokens, outputTokens, model, thinkingFormat, integrity.StopReason) w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(resp) return diff --git a/proxy/handler_test.go b/proxy/handler_test.go index 728f88f7..1b4a01a7 100644 --- a/proxy/handler_test.go +++ b/proxy/handler_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" ) @@ -68,6 +69,9 @@ func TestClaudeNonStreamRetriesNextAccountAfterPreResponseFailure(t *testing.T) _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{ "content": "retried successfully", })) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{ + "stopReason": "end_turn", + })) })) defer server.Close() @@ -98,7 +102,7 @@ func TestClaudeNonStreamRetriesNextAccountAfterPreResponseFailure(t *testing.T) } rec := httptest.NewRecorder() - h.handleClaudeNonStream(rec, payload, "claude-sonnet-4.5", false, claudeThinkingResponseOptions{}, 1, nil, "") + h.handleClaudeNonStream(rec, payload, "claude-sonnet-4.5", false, claudeThinkingResponseOptions{}, 1, nil, "", nil) if rec.Code != http.StatusOK { t.Fatalf("expected retry to succeed, status=%d body=%s", rec.Code, rec.Body.String()) @@ -480,3 +484,211 @@ func TestBuildAnthropicModelsResponseGeneratesThinkingVariants(t *testing.T) { t.Fatalf("expected image capability to be preserved, got %#v", models[0]["supports_image"]) } } + +func TestClaudeStreamRetryDiscardsBufferedPartialText(t *testing.T) { + cfgFile := t.TempDir() + "/config.json" + if err := config.Init(cfgFile); err != nil { + t.Fatalf("config.Init: %v", err) + } + if err := config.AddAccount(config.Account{ + ID: "only", + Enabled: true, + AccessToken: "token", + ProfileArn: "arn:aws:codewhisperer:profile/only", + }); err != nil { + t.Fatalf("add account: %v", err) + } + if err := config.UpdatePreferredEndpoint("kiro"); err != nil { + t.Fatalf("set endpoint: %v", err) + } + if err := config.UpdateEndpointFallback(false); err != nil { + t.Fatalf("disable fallback: %v", err) + } + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hits.Add(1) == 1 { + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "stale"})) + return + } + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "fresh"})) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{"stopReason": "MAX_TOKENS"})) + })) + defer server.Close() + + oldEndpoints := kiroEndpoints + kiroEndpoints = []kiroEndpoint{{URL: server.URL, Origin: "AI_EDITOR", Name: "test"}} + defer func() { kiroEndpoints = oldEndpoints }() + oldClient := kiroHttpStore.Load() + kiroHttpStore.Store(&http.Client{Timeout: time.Second, Transport: &http.Transport{}}) + defer kiroHttpStore.Store(oldClient) + + p := accountpool.GetPool() + p.Reload() + h := &Handler{pool: p, promptCache: newPromptCacheTracker(defaultPromptCacheTTL)} + payload := &KiroPayload{} + payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{ + Content: "hello", ModelID: "claude-sonnet-4.5", Origin: "AI_EDITOR", + } + + rec := httptest.NewRecorder() + h.handleClaudeStream(rec, payload, "claude-sonnet-4.5", false, claudeThinkingResponseOptions{}, 1, nil, "", []ClaudeMessage{{Role: "user", Content: "hello"}}) + body := rec.Body.String() + if hits.Load() != 2 { + t.Fatalf("upstream hits = %d, want one retry", hits.Load()) + } + if strings.Contains(body, "stale") { + t.Fatalf("retry leaked first attempt text into SSE: %s", body) + } + if !strings.Contains(body, "fresh") { + t.Fatalf("retry response missing fresh text: %s", body) + } + if !strings.Contains(body, `"stop_reason":"max_tokens"`) { + t.Fatalf("Claude stop reason was not normalized: %s", body) + } +} + +func TestOpenAIStreamRetryDiscardsBufferedPartialText(t *testing.T) { + cfgFile := t.TempDir() + "/config.json" + if err := config.Init(cfgFile); err != nil { + t.Fatalf("config.Init: %v", err) + } + if err := config.AddAccount(config.Account{ + ID: "only", + Enabled: true, + AccessToken: "token", + ProfileArn: "arn:aws:codewhisperer:profile/only", + }); err != nil { + t.Fatalf("add account: %v", err) + } + if err := config.UpdatePreferredEndpoint("kiro"); err != nil { + t.Fatalf("set endpoint: %v", err) + } + if err := config.UpdateEndpointFallback(false); err != nil { + t.Fatalf("disable fallback: %v", err) + } + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hits.Add(1) == 1 { + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "stale"})) + return + } + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "fresh"})) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{"stopReason": "MAX_TOKENS"})) + })) + defer server.Close() + + oldEndpoints := kiroEndpoints + kiroEndpoints = []kiroEndpoint{{URL: server.URL, Origin: "AI_EDITOR", Name: "test"}} + defer func() { kiroEndpoints = oldEndpoints }() + oldClient := kiroHttpStore.Load() + kiroHttpStore.Store(&http.Client{Timeout: time.Second, Transport: &http.Transport{}}) + defer kiroHttpStore.Store(oldClient) + + p := accountpool.GetPool() + p.Reload() + h := &Handler{pool: p, promptCache: newPromptCacheTracker(defaultPromptCacheTTL)} + payload := &KiroPayload{} + payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{ + Content: "hello", ModelID: "claude-sonnet-4.5", Origin: "AI_EDITOR", + } + + rec := httptest.NewRecorder() + h.handleOpenAIStream(rec, payload, "claude-sonnet-4.5", false, 1, "") + body := rec.Body.String() + if hits.Load() != 2 { + t.Fatalf("upstream hits = %d, want one retry", hits.Load()) + } + if strings.Contains(body, "stale") { + t.Fatalf("retry leaked first attempt text into SSE: %s", body) + } + if !strings.Contains(body, "fresh") { + t.Fatalf("retry response missing fresh text: %s", body) + } + if !strings.Contains(body, `"finish_reason":"length"`) { + t.Fatalf("OpenAI finish reason was not mapped: %s", body) + } +} + +func TestOpenAIStreamSignalsErrorAfterPartialFlush(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + if err := config.AddAccount(config.Account{ + ID: "only", Enabled: true, AccessToken: "token", + ProfileArn: "arn:aws:codewhisperer:profile/only", + }); err != nil { + t.Fatalf("add account: %v", err) + } + if err := config.UpdatePreferredEndpoint("kiro"); err != nil { + t.Fatalf("set endpoint: %v", err) + } + if err := config.UpdateEndpointFallback(false); err != nil { + t.Fatalf("disable fallback: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": strings.Repeat("partial ", 8)})) + // No metadataEvent: output has already flushed, so retry is unsafe. + })) + defer server.Close() + defer swapKiroEndpointsForTest(t, server)() + + p := accountpool.GetPool() + p.Reload() + h := &Handler{pool: p, promptCache: newPromptCacheTracker(defaultPromptCacheTTL)} + payload := &KiroPayload{} + payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{ + Content: "hello", ModelID: "claude-sonnet-4.5", Origin: "AI_EDITOR", + } + rec := httptest.NewRecorder() + h.handleOpenAIStream(rec, payload, "claude-sonnet-4.5", false, 1, "") + body := rec.Body.String() + if !strings.Contains(body, `"error":{"message":`) || !strings.Contains(body, `"type":"server_error"`) { + t.Fatalf("post-flush stream missing explicit error event: %s", body) + } + if strings.Contains(body, "data: [DONE]") || strings.Contains(body, `"finish_reason":"stop"`) { + t.Fatalf("failed stream must not emit a success terminator: %s", body) + } +} + +func TestClaudeNonStreamIntegrityExhaustionExcludesAccount(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + if err := config.AddAccount(config.Account{ + ID: "only", Enabled: true, AccessToken: "token", + ProfileArn: "arn:aws:codewhisperer:profile/only", + }); err != nil { + t.Fatalf("add account: %v", err) + } + if err := config.UpdatePreferredEndpoint("kiro"); err != nil { + t.Fatalf("set endpoint: %v", err) + } + if err := config.UpdateEndpointFallback(false); err != nil { + t.Fatalf("disable fallback: %v", err) + } + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + defer swapKiroEndpointsForTest(t, server)() + + p := accountpool.GetPool() + p.Reload() + h := &Handler{pool: p, promptCache: newPromptCacheTracker(defaultPromptCacheTTL)} + payload := &KiroPayload{} + payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{ + Content: "hello", ModelID: "claude-sonnet-4.5", Origin: "AI_EDITOR", + } + h.handleClaudeNonStream(httptest.NewRecorder(), payload, "claude-sonnet-4.5", false, claudeThinkingResponseOptions{}, 1, nil, "", nil) + + want := int32(maxSameAccountStreamRetries + 1) + if hits.Load() != want { + t.Fatalf("empty account called %d times, want one same-account retry budget (%d)", hits.Load(), want) + } +} diff --git a/proxy/responses_handler.go b/proxy/responses_handler.go index a58106f6..7dbd9daf 100644 --- a/proxy/responses_handler.go +++ b/proxy/responses_handler.go @@ -150,27 +150,42 @@ func (h *Handler) handleResponsesNonStream( var credits float64 var realInputTokens int - callback := &KiroStreamCallback{ - OnText: func(text string, isThinking bool) { - if isThinking { - reasoningContent += text - } else { - content += text + var integrity streamIntegrityState + + err := runKiroWithIntegrityRetry(account, payload, + &integrity, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnText: func(text string, isThinking bool) { + integrity.observeText(text, isThinking) + if isThinking { + reasoningContent += text + } else { + content += text + } + }, + OnToolUse: func(tu KiroToolUse) { integrity.observeToolUse(); toolUses = append(toolUses, tu) }, + OnComplete: func(inTok, outTok int) { inputTokens = inTok; outputTokens = outTok }, + OnCredits: func(c float64) { credits = c }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, + OnStopReason: func(reason string) { integrity.observeStopReason(reason) }, } }, - OnToolUse: func(tu KiroToolUse) { toolUses = append(toolUses, tu) }, - OnComplete: func(inTok, outTok int) { inputTokens = inTok; outputTokens = outTok }, - OnCredits: func(c float64) { credits = c }, - OnContextUsage: func(pct float64) { - realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + func() { + content, reasoningContent = "", "" + toolUses = nil + inputTokens, outputTokens, credits, realInputTokens = 0, 0, 0, 0 }, - } - - err := CallKiroAPI(account, payload, callback) + nil, + ) if err != nil { lastErr = err excluded[account.ID] = true - h.handleAccountFailure(account, err) + if !isStreamIntegrityError(err) { + h.handleAccountFailure(account, err) + } continue } @@ -191,7 +206,7 @@ func (h *Handler) handleResponsesNonStream( h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) h.recordSuccessLog("responses", model, account.ID, inputTokens+outputTokens, credits, time.Since(reqStart).Milliseconds()) - respObj := buildResponsesObject(respID, model, finalContent, toolUses, inputTokens, outputTokens, req) + respObj := buildResponsesObject(respID, model, finalContent, toolUses, inputTokens, outputTokens, integrity.StopReason, req) respObj.StoredInput = storedInput respObj.Instructions = req.Instructions @@ -216,16 +231,17 @@ func (h *Handler) handleResponsesNonStream( func buildResponsesObject( id, model, content string, toolUses []KiroToolUse, - inputTokens, outputTokens int, req *ResponsesRequest, + inputTokens, outputTokens int, stopReason string, req *ResponsesRequest, ) *ResponsesObject { output := make([]ResponseOutputItem, 0, 1+len(toolUses)) + status, incompleteReason := mapResponsesCompletion(stopReason) if strings.TrimSpace(content) != "" { output = append(output, ResponseOutputItem{ ID: generateOutputItemID("msg"), Type: "message", Role: "assistant", - Status: "completed", + Status: status, Content: []ResponseContentPart{{ Type: "output_text", Text: content, @@ -250,7 +266,7 @@ func buildResponsesObject( ID: generateOutputItemID("msg"), Type: "message", Role: "assistant", - Status: "completed", + Status: status, Content: []ResponseContentPart{{ Type: "output_text", Text: "", @@ -258,16 +274,22 @@ func buildResponsesObject( }) } + var incompleteDetails *ResponsesIncompleteDetails + if incompleteReason != "" { + incompleteDetails = &ResponsesIncompleteDetails{Reason: incompleteReason} + } + return &ResponsesObject{ ID: id, Object: "response", CreatedAt: time.Now().Unix(), - Status: "completed", + Status: status, Model: model, Output: output, Usage: ResponsesUsage{InputTokens: inputTokens, OutputTokens: outputTokens, TotalTokens: inputTokens + outputTokens}, PreviousResponseID: req.PreviousResponseID, Metadata: req.Metadata, + IncompleteDetails: incompleteDetails, } } @@ -316,6 +338,10 @@ func (h *Handler) handleResponsesStream( var lastErr error responseStarted := false reqStart := time.Now() + send("response.in_progress", map[string]interface{}{ + "type": "response.in_progress", + "response": initial, + }) for attempt := 0; attempt < maxAccountRetryAttempts; attempt++ { account := h.pool.GetNextForModelExcluding(model, excluded) @@ -329,11 +355,6 @@ func (h *Handler) handleResponsesStream( continue } - send("response.in_progress", map[string]interface{}{ - "type": "response.in_progress", - "response": initial, - }) - var ( fullText strings.Builder reasoningText strings.Builder @@ -377,101 +398,138 @@ func (h *Handler) handleResponsesStream( }) } - callback := &KiroStreamCallback{ - OnText: func(text string, isThinking bool) { - if text == "" { - return - } - if isThinking { - reasoningText.WriteString(text) - return - } - fullText.WriteString(text) - ensureMessageStarted() - send("response.output_text.delta", map[string]interface{}{ - "type": "response.output_text.delta", - "item_id": messageItemID, - "output_index": outputIndex, - "content_index": contentIndex, - "delta": text, - }) - responseStarted = true - }, - OnToolUse: func(tu KiroToolUse) { - if messageStarted { - send("response.content_part.done", map[string]interface{}{ - "type": "response.content_part.done", - "item_id": messageItemID, - "output_index": outputIndex, - "content_index": contentIndex, - "part": map[string]interface{}{ - "type": "output_text", - "text": fullText.String(), - }, - }) - send("response.output_item.done", map[string]interface{}{ - "type": "response.output_item.done", - "output_index": outputIndex, - "item": map[string]interface{}{ - "id": messageItemID, - "type": "message", - "role": "assistant", - "status": "completed", - "content": []map[string]interface{}{{ - "type": "output_text", - "text": fullText.String(), - }}, - }, - }) - messageStarted = false - outputIndex++ - } - - toolUses = append(toolUses, tu) - args, _ := json.Marshal(tu.Input) - fcID := generateOutputItemID("fc") - send("response.output_item.added", map[string]interface{}{ - "type": "response.output_item.added", - "output_index": outputIndex, - "item": map[string]interface{}{ - "id": fcID, - "type": "function_call", - "status": "in_progress", - "call_id": tu.ToolUseID, - "name": tu.Name, - "arguments": "", + var integrity streamIntegrityState + + err := runKiroWithIntegrityRetry(account, payload, + &integrity, + func() *KiroStreamCallback { + return &KiroStreamCallback{ + OnText: func(text string, isThinking bool) { + if text == "" { + return + } + integrity.observeText(text, isThinking) + if isThinking { + reasoningText.WriteString(text) + return + } + fullText.WriteString(text) + ensureMessageStarted() + send("response.output_text.delta", map[string]interface{}{ + "type": "response.output_text.delta", + "item_id": messageItemID, + "output_index": outputIndex, + "content_index": contentIndex, + "delta": text, + }) + responseStarted = true }, - }) - send("response.function_call_arguments.delta", map[string]interface{}{ - "type": "response.function_call_arguments.delta", - "item_id": fcID, - "output_index": outputIndex, - "delta": string(args), - }) - send("response.output_item.done", map[string]interface{}{ - "type": "response.output_item.done", - "output_index": outputIndex, - "item": map[string]interface{}{ - "id": fcID, - "type": "function_call", - "status": "completed", - "call_id": tu.ToolUseID, - "name": tu.Name, - "arguments": string(args), + OnToolUse: func(tu KiroToolUse) { + integrity.observeToolUse() + if messageStarted { + messageStatus, _ := mapResponsesCompletion(integrity.StopReason) + send("response.content_part.done", map[string]interface{}{ + "type": "response.content_part.done", + "item_id": messageItemID, + "output_index": outputIndex, + "content_index": contentIndex, + "part": map[string]interface{}{ + "type": "output_text", + "text": fullText.String(), + }, + }) + send("response.output_item.done", map[string]interface{}{ + "type": "response.output_item.done", + "output_index": outputIndex, + "item": map[string]interface{}{ + "id": messageItemID, + "type": "message", + "role": "assistant", + "status": messageStatus, + "content": []map[string]interface{}{{ + "type": "output_text", + "text": fullText.String(), + }}, + }, + }) + messageStarted = false + outputIndex++ + } + + toolUses = append(toolUses, tu) + args, _ := json.Marshal(tu.Input) + fcID := generateOutputItemID("fc") + send("response.output_item.added", map[string]interface{}{ + "type": "response.output_item.added", + "output_index": outputIndex, + "item": map[string]interface{}{ + "id": fcID, + "type": "function_call", + "status": "in_progress", + "call_id": tu.ToolUseID, + "name": tu.Name, + "arguments": "", + }, + }) + send("response.function_call_arguments.delta", map[string]interface{}{ + "type": "response.function_call_arguments.delta", + "item_id": fcID, + "output_index": outputIndex, + "delta": string(args), + }) + send("response.output_item.done", map[string]interface{}{ + "type": "response.output_item.done", + "output_index": outputIndex, + "item": map[string]interface{}{ + "id": fcID, + "type": "function_call", + "status": "completed", + "call_id": tu.ToolUseID, + "name": tu.Name, + "arguments": string(args), + }, + }) + outputIndex++ + responseStarted = true }, - }) - outputIndex++ - responseStarted = true + OnComplete: func(inTok, outTok int) { inputTokens = inTok; outputTokens = outTok }, + OnCredits: func(c float64) { credits = c }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, + OnStopReason: func(reason string) { integrity.observeStopReason(reason) }, + } }, - OnComplete: func(inTok, outTok int) { inputTokens = inTok; outputTokens = outTok }, - OnCredits: func(c float64) { credits = c }, - OnContextUsage: func(pct float64) { - realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + func() { + fullText.Reset() + reasoningText.Reset() + toolUses = nil + inputTokens, outputTokens, credits, realInputTokens = 0, 0, 0, 0 }, - } - - err := CallKiroAPI(account, payload, callback) + func() bool { return !responseStarted }, + ) if err != nil { + if isStreamIntegrityError(err) { + if !responseStarted { + lastErr = err + excluded[account.ID] = true + continue + } + // Partial content already reached the client — do not forge completed. + send("response.failed", map[string]interface{}{ + "type": "response.failed", + "response": map[string]interface{}{ + "id": respID, + "status": "failed", + "error": map[string]string{ + "type": "server_error", + "message": err.Error(), + }, + }, + }) + h.recordFailureWithDetails("responses", model, account.ID, err) + return + } if !responseStarted { lastErr = err excluded[account.ID] = true @@ -499,6 +557,8 @@ func (h *Handler) handleResponsesStream( reasoning = "" } + responseStatus, _ := mapResponsesCompletion(integrity.StopReason) + if messageStarted { send("response.content_part.done", map[string]interface{}{ "type": "response.content_part.done", @@ -517,7 +577,7 @@ func (h *Handler) handleResponsesStream( "id": messageItemID, "type": "message", "role": "assistant", - "status": "completed", + "status": responseStatus, "content": []map[string]interface{}{{ "type": "output_text", "text": finalContent, @@ -538,7 +598,7 @@ func (h *Handler) handleResponsesStream( h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) h.recordSuccessLog("responses", model, account.ID, inputTokens+outputTokens, credits, time.Since(reqStart).Milliseconds()) - respObj := buildResponsesObject(respID, model, finalContent, toolUses, inputTokens, outputTokens, req) + respObj := buildResponsesObject(respID, model, finalContent, toolUses, inputTokens, outputTokens, integrity.StopReason, req) respObj.CreatedAt = createdAt respObj.StoredInput = storedInput respObj.Instructions = req.Instructions @@ -549,8 +609,9 @@ func (h *Handler) handleResponsesStream( } } - send("response.completed", map[string]interface{}{ - "type": "response.completed", + completionEvent := "response." + respObj.Status + send(completionEvent, map[string]interface{}{ + "type": completionEvent, "response": respObj, }) fmt.Fprintf(w, "data: [DONE]\n\n") diff --git a/proxy/responses_handler_test.go b/proxy/responses_handler_test.go index 72003eb9..f4a13071 100644 --- a/proxy/responses_handler_test.go +++ b/proxy/responses_handler_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "path/filepath" "strings" + "sync/atomic" "testing" "time" ) @@ -234,6 +235,9 @@ func TestResponsesContinuationKeepsNewInstructions(t *testing.T) { _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{ "content": "second reply", })) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{ + "stopReason": "end_turn", + })) })) defer server.Close() defer swapKiroEndpointsForTest(t, server)() @@ -312,6 +316,9 @@ func TestResponsesNonStreamRoundTrip(t *testing.T) { _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{ "content": "responses non-stream OK", })) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{ + "stopReason": "end_turn", + })) })) defer server.Close() defer swapKiroEndpointsForTest(t, server)() @@ -365,6 +372,9 @@ func TestResponsesStreamSSE(t *testing.T) { _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{ "content": "stream chunk", })) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{ + "stopReason": "end_turn", + })) })) defer server.Close() defer swapKiroEndpointsForTest(t, server)() @@ -391,3 +401,103 @@ func TestResponsesStreamSSE(t *testing.T) { t.Fatalf("expected stream content delta, got:\n%s", bodyStr) } } + +func TestResponsesStreamMaxTokensIsIncomplete(t *testing.T) { + h, cleanup := setupResponsesTestHandler(t) + defer cleanup() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "partial output"})) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{"stopReason": "MAX_TOKENS"})) + })) + defer server.Close() + defer swapKiroEndpointsForTest(t, server)() + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader( + `{"model":"claude-sonnet-4.5","input":"write a lot","stream":true,"store":false}`, + )) + rec := httptest.NewRecorder() + h.handleOpenAIResponses(rec, req) + body := rec.Body.String() + + if !strings.Contains(body, "event: response.incomplete") { + t.Fatalf("missing response.incomplete event:\n%s", body) + } + if !strings.Contains(body, `"status":"incomplete"`) || + !strings.Contains(body, `"incomplete_details":{"reason":"max_output_tokens"}`) { + t.Fatalf("missing incomplete status details:\n%s", body) + } +} + +func TestResponsesStreamMaxTokensMarksPreToolMessageIncomplete(t *testing.T) { + h, cleanup := setupResponsesTestHandler(t) + defer cleanup() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{"content": "partial text"})) + _, _ = w.Write(awsEventStreamFrame(t, "toolUseEvent", map[string]interface{}{ + "toolUseId": "call_1", "name": "read_file", "input": `{"path":"a.go"}`, "stop": true, + })) + _, _ = w.Write(awsEventStreamFrame(t, "metadataEvent", map[string]interface{}{"stopReason": "MAX_TOKENS"})) + })) + defer server.Close() + defer swapKiroEndpointsForTest(t, server)() + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader( + `{"model":"claude-sonnet-4.5","input":"write a lot","stream":true,"store":false}`, + )) + rec := httptest.NewRecorder() + h.handleOpenAIResponses(rec, req) + + foundMessage := false + for _, line := range strings.Split(rec.Body.String(), "\n") { + if !strings.HasPrefix(line, "data: {") { + continue + } + var event map[string]interface{} + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event); err != nil { + continue + } + if event["type"] != "response.output_item.done" { + continue + } + item, _ := event["item"].(map[string]interface{}) + if item["type"] != "message" { + continue + } + foundMessage = true + if item["status"] != "incomplete" { + t.Fatalf("pre-tool message status = %v, body:\n%s", item["status"], rec.Body.String()) + } + } + if !foundMessage { + t.Fatalf("missing pre-tool message completion event:\n%s", rec.Body.String()) + } +} + +func TestResponsesIntegrityExhaustionRotatesOnce(t *testing.T) { + h, cleanup := setupResponsesTestHandler(t) + defer cleanup() + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + defer swapKiroEndpointsForTest(t, server)() + + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader( + `{"model":"claude-sonnet-4.5","input":"hello","stream":true,"store":false}`, + )) + rec := httptest.NewRecorder() + h.handleOpenAIResponses(rec, req) + + want := int32(maxSameAccountStreamRetries + 1) + if hits.Load() != want { + t.Fatalf("empty account called %d times, want one same-account retry budget (%d)", hits.Load(), want) + } + if count := strings.Count(rec.Body.String(), "event: response.in_progress"); count != 1 { + t.Fatalf("response.in_progress emitted %d times:\n%s", count, rec.Body.String()) + } +} diff --git a/proxy/responses_types.go b/proxy/responses_types.go index c36413a0..4c2dd688 100644 --- a/proxy/responses_types.go +++ b/proxy/responses_types.go @@ -17,20 +17,21 @@ type ResponsesRequest struct { } type ResponsesObject struct { - ID string `json:"id"` - Object string `json:"object"` - CreatedAt int64 `json:"created_at"` - Status string `json:"status"` - Model string `json:"model"` - Output []ResponseOutputItem `json:"output"` - Usage ResponsesUsage `json:"usage"` - PreviousResponseID string `json:"previous_response_id,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Error *ResponsesError `json:"error,omitempty"` - Instructions string `json:"instructions,omitempty"` - StoredInput json.RawMessage `json:"-"` - StoredInstr string `json:"-"` - StoredAt int64 `json:"stored_at,omitempty"` + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Status string `json:"status"` + Model string `json:"model"` + Output []ResponseOutputItem `json:"output"` + Usage ResponsesUsage `json:"usage"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Error *ResponsesError `json:"error,omitempty"` + IncompleteDetails *ResponsesIncompleteDetails `json:"incomplete_details,omitempty"` + Instructions string `json:"instructions,omitempty"` + StoredInput json.RawMessage `json:"-"` + StoredInstr string `json:"-"` + StoredAt int64 `json:"stored_at,omitempty"` } type ResponseOutputItem struct { @@ -60,3 +61,7 @@ type ResponsesError struct { Code string `json:"code,omitempty"` Message string `json:"message"` } + +type ResponsesIncompleteDetails struct { + Reason string `json:"reason"` +} diff --git a/proxy/websearch.go b/proxy/websearch.go index 2996e459..4eb26f55 100644 --- a/proxy/websearch.go +++ b/proxy/websearch.go @@ -281,7 +281,7 @@ func callMcpAPI(account *config.Account, mcpReq *McpRequest) (*McpResponse, erro if parsed, perr := url.Parse(endpoint); perr == nil { host = parsed.Host } - headerValues := buildStreamingHeaderValues(account, host) + headerValues := buildLegacyStreamingHeaderValues(account, host) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "*/*") diff --git a/proxy/websearch_loop.go b/proxy/websearch_loop.go index 352485f0..e2603c3a 100644 --- a/proxy/websearch_loop.go +++ b/proxy/websearch_loop.go @@ -25,11 +25,13 @@ const maxWebSearchRounds = 5 // webSearchRoundOutcome is one buffered generateAssistantResponse round. type webSearchRoundOutcome struct { - text string - toolUses []KiroToolUse - inputTokens int - credits float64 - stopReasonOverride string + text string + toolUses []KiroToolUse + inputTokens int + credits float64 + stopReason string + convID string + contID string } // runWebSearchLoop is the mixed-tools entry point. @@ -50,7 +52,7 @@ func (h *Handler) runWebSearchLoop(w http.ResponseWriter, req *ClaudeRequest, th // Allow one extra iteration so a terminal flush can run after the last // search-only round (same pattern as 0..=MAX_WEB_SEARCH_ROUNDS in kiro-rs). for roundIdx := 0; roundIdx <= maxUses; roundIdx++ { - round, account, err := h.callUpstreamForWebSearch(&working, thinking, fallbackInput) + round, account, err := h.callUpstreamForWebSearch(&working, thinking, fallbackInput, apiKeyID) if err != nil { logger.Warnf("[WebSearchLoop] upstream round %d failed: %v", roundIdx, err) accountID := "" @@ -115,7 +117,7 @@ func (h *Handler) runWebSearchLoop(w http.ResponseWriter, req *ClaudeRequest, th } content := buildFlushContent(presentation, round.text, round.toolUses, searched) - stopReason := resolveFlushStopReason(round.stopReasonOverride, round.toolUses, content) + stopReason := resolveFlushStopReason(round.stopReason, round.toolUses, content) outputTokens := estimateContentBlocksTokens(content) inputTokens := round.inputTokens if inputTokens <= 0 { @@ -128,6 +130,11 @@ func (h *Handler) runWebSearchLoop(w http.ResponseWriter, req *ClaudeRequest, th } h.recordSuccessForApiKey(apiKeyID, inputTokens, outputTokens, totalCredits) h.recordSuccessLog("claude", req.Model, lastAccountID, inputTokens+outputTokens, totalCredits, time.Since(reqStart).Milliseconds()) + // Seal the exact assistant content the client receives so the next + // client-side tool_result continuation reuses the same IDs. + if round.convID != "" && round.contID != "" { + sealClaudeSessionFromContent(apiKeyID, req.Model, round.convID, round.contID, req.Messages, content) + } if req.Stream { h.renderWebSearchLoopSSE(w, req.Model, content, stopReason, inputTokens, outputTokens) @@ -141,8 +148,14 @@ func (h *Handler) runWebSearchLoop(w http.ResponseWriter, req *ClaudeRequest, th } // callUpstreamForWebSearch converts the Claude request and buffers one Kiro stream. -func (h *Handler) callUpstreamForWebSearch(req *ClaudeRequest, thinking bool, estimatedInputTokens int) (*webSearchRoundOutcome, *config.Account, error) { +func (h *Handler) callUpstreamForWebSearch(req *ClaudeRequest, thinking bool, estimatedInputTokens int, apiKeyID string) (*webSearchRoundOutcome, *config.Account, error) { payload := ClaudeToKiro(req, thinking) + modelID := MapModel(req.Model) + convID, contID, _ := resolveSessionIDs(apiKeyID, modelID, req.Messages) + payload.ConversationState.ConversationID = convID + payload.ConversationState.AgentContinuationId = contID + attachStoredReasoning(payload.ConversationState.History, apiKeyID, modelID, convID) + excluded := make(map[string]bool) var lastErr error @@ -158,48 +171,71 @@ func (h *Handler) callUpstreamForWebSearch(req *ClaudeRequest, thinking bool, es continue } - var text string + var text, thinkingText string var toolUses []KiroToolUse var inputTokens int var credits float64 var realInputTokens int - var stopOverride string - - callback := &KiroStreamCallback{ - OnText: func(t string, isThinking bool) { - if isThinking { - return + var integrity streamIntegrityState + var stamp reasoningCapture + + err := runKiroWithIntegrityRetry( + account, + payload, + &integrity, + func() *KiroStreamCallback { + onReasoningMeta := stamp.meta() + return &KiroStreamCallback{ + OnText: func(t string, isThinking bool) { + integrity.observeText(t, isThinking) + if isThinking { + thinkingText += t + return + } + text += t + }, + OnToolUse: func(tu KiroToolUse) { + toolUses = append(toolUses, tu) + integrity.observeToolUse() + }, + OnComplete: func(inTok, _ int) { + inputTokens = inTok + }, + OnCredits: func(c float64) { + credits = c + }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(req.Model)) / 100.0) + if pct >= 100.0 { + integrity.observeStopReason("model_context_window_exceeded") + } + }, + OnStopReason: func(reason string) { + if integrity.StopReason == "" { + integrity.observeStopReason(reason) + } + }, + OnReasoningMeta: func(signature, redacted string) { + integrity.SawReasoning = true + onReasoningMeta(signature, redacted) + }, } - text += t - }, - OnToolUse: func(tu KiroToolUse) { - toolUses = append(toolUses, tu) - }, - OnComplete: func(inTok, outTok int) { - inputTokens = inTok - _ = outTok }, - OnCredits: func(c float64) { - credits = c - }, - OnContextUsage: func(pct float64) { - realInputTokens = int(pct * float64(getContextWindowSize(req.Model)) / 100.0) - if pct >= 100.0 { - stopOverride = "model_context_window_exceeded" - } - }, - OnError: func(err error) { - if err != nil { - lastErr = err - } + func() { + text, thinkingText = "", "" + toolUses = nil + inputTokens, realInputTokens = 0, 0 + credits = 0 + stamp.reset() }, - } - - err := CallKiroAPI(account, payload, callback) + nil, + ) if err != nil { lastErr = err excluded[account.ID] = true - h.handleAccountFailure(account, err) + if !isStreamIntegrityError(err) { + h.handleAccountFailure(account, err) + } continue } @@ -209,12 +245,20 @@ func (h *Handler) callUpstreamForWebSearch(req *ClaudeRequest, thinking bool, es inputTokens = estimatedInputTokens } + // Intermediate search-only rounds still seal the raw upstream shape so + // the next loop iteration reuses the same IDs. The final client-facing + // flush seals the visible server_tool_use content separately. + stamp.sealIfPresent(apiKeyID, modelID, convID, currentTurnParentUser(payload), text, toolUses, thinkingText) + sealClaudeSession(apiKeyID, req.Model, convID, contID, req.Messages, text, "", toolUses, "", true) + return &webSearchRoundOutcome{ - text: text, - toolUses: toolUses, - inputTokens: inputTokens, - credits: credits, - stopReasonOverride: stopOverride, + text: text, + toolUses: toolUses, + inputTokens: inputTokens, + credits: credits, + stopReason: integrity.StopReason, + convID: convID, + contID: contID, }, account, nil } @@ -386,23 +430,20 @@ func buildFlushContent( // resolveFlushStopReason picks stop_reason for the flushed response. // web_search-only rounds end as end_turn; client tool_use yields tool_use. -func resolveFlushStopReason(override string, toolUses []KiroToolUse, content []map[string]interface{}) string { - if override != "" { - return override - } - for _, c := range content { - if c["type"] == "tool_use" { - if name, _ := c["name"].(string); name != webSearchToolName { - return "tool_use" +func resolveFlushStopReason(reason string, toolUses []KiroToolUse, content []map[string]interface{}) string { + for _, block := range content { + if block["type"] == "tool_use" { + if name, _ := block["name"].(string); name != webSearchToolName { + return mapClaudeStopReason(reason, 1) } } } - for _, tu := range toolUses { - if tu.Name != webSearchToolName { - return "tool_use" + for _, toolUse := range toolUses { + if toolUse.Name != webSearchToolName { + return mapClaudeStopReason(reason, 1) } } - return "end_turn" + return mapClaudeStopReason(reason, 0) } func estimateContentBlocksTokens(content []map[string]interface{}) int { diff --git a/proxy/websearch_test.go b/proxy/websearch_test.go index d93a1ee1..11976573 100644 --- a/proxy/websearch_test.go +++ b/proxy/websearch_test.go @@ -490,7 +490,10 @@ func TestAppendSearchRound_ContentSurvivesClaudeToKiro(t *testing.T) { if aText != "Looking it up." || len(aUses) != 1 || aUses[0].ToolUseID != "toolu_ws_1" { t.Fatalf("assistant extract failed: text=%q uses=%+v", aText, aUses) } - _, _, uResults := extractClaudeUserContent(req.Messages[2].Content) + _, _, _, uResults, errMsg := extractClaudeUserContent(req.Messages[2].Content) + if errMsg != "" { + t.Fatalf("unexpected document error: %s", errMsg) + } if len(uResults) != 1 || uResults[0].ToolUseID != "toolu_ws_1" { t.Fatalf("user tool_result extract failed: %+v", uResults) } @@ -529,7 +532,10 @@ func TestExtractClaudeContent_MapSliceShape(t *testing.T) { user := []map[string]interface{}{ {"type": "tool_result", "tool_use_id": "id1", "content": "ok"}, } - _, _, results := extractClaudeUserContent(user) + _, _, _, results, errMsg := extractClaudeUserContent(user) + if errMsg != "" { + t.Fatalf("unexpected document error: %s", errMsg) + } if len(results) != 1 || results[0].ToolUseID != "id1" { t.Fatalf("map-slice user content: %+v", results) } @@ -559,3 +565,234 @@ func TestParseSearchResults_EmptyResultsArrayIsValid(t *testing.T) { t.Fatalf("results len = %d", len(results.Results)) } } + +func TestClaudeToKiroFoldsServerWebSearchResultsIntoHistory(t *testing.T) { + snippet := "Go 1.18 introduced generics" + req := &ClaudeRequest{ + Model: "claude-sonnet-4.5", + Messages: []ClaudeMessage{ + {Role: "user", Content: "search and then run bash"}, + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "I searched first."}, + map[string]interface{}{ + "type": "server_tool_use", + "id": "srvtoolu_1", + "name": "web_search", + "input": map[string]interface{}{"query": "golang generics"}, + }, + map[string]interface{}{ + "type": "web_search_tool_result", + "content": []interface{}{ + map[string]interface{}{ + "type": "web_search_result", + "title": "Generics", + "url": "https://go.dev", + "encrypted_content": snippet, + }, + }, + }, + map[string]interface{}{ + "type": "tool_use", + "id": "toolu_bash", + "name": "Bash", + "input": map[string]interface{}{"command": "ls"}, + }, + }}, + {Role: "user", Content: []interface{}{ + map[string]interface{}{"type": "tool_result", "tool_use_id": "toolu_bash", "content": "a.go"}, + }}, + }, + } + + payload := ClaudeToKiro(req, false) + if payload == nil { + t.Fatal("nil payload") + } + history := payload.ConversationState.History + if len(history) == 0 { + t.Fatal("expected history") + } + last := history[len(history)-1] + if last.AssistantResponseMessage == nil { + t.Fatal("expected assistant history entry") + } + if !strings.Contains(last.AssistantResponseMessage.Content, "golang generics") { + t.Fatalf("assistant history missing folded search summary: %q", last.AssistantResponseMessage.Content) + } + if !strings.Contains(last.AssistantResponseMessage.Content, "https://go.dev") { + t.Fatalf("assistant history missing search source: %q", last.AssistantResponseMessage.Content) + } + if len(last.AssistantResponseMessage.ToolUses) != 1 || last.AssistantResponseMessage.ToolUses[0].ToolUseID != "toolu_bash" { + t.Fatalf("client tool uses = %+v, want only Bash", last.AssistantResponseMessage.ToolUses) + } +} + +func TestClaudeToKiroFoldsEmptyServerWebSearchResults(t *testing.T) { + req := &ClaudeRequest{ + Model: "claude-sonnet-4.5", + Messages: []ClaudeMessage{ + {Role: "user", Content: "search and then run bash"}, + {Role: "assistant", Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "I searched first."}, + map[string]interface{}{ + "type": "server_tool_use", + "id": "srvtoolu_empty", + "name": "web_search", + "input": map[string]interface{}{"query": "unlikely query"}, + }, + map[string]interface{}{ + "type": "web_search_tool_result", + "content": []interface{}{}, + }, + map[string]interface{}{ + "type": "tool_use", + "id": "toolu_bash", + "name": "Bash", + "input": map[string]interface{}{"command": "ls"}, + }, + }}, + {Role: "user", Content: []interface{}{ + map[string]interface{}{"type": "tool_result", "tool_use_id": "toolu_bash", "content": "a.go"}, + }}, + }, + } + + payload := ClaudeToKiro(req, false) + last := payload.ConversationState.History[len(payload.ConversationState.History)-1] + if last.AssistantResponseMessage == nil { + t.Fatal("expected assistant history entry") + } + if !strings.Contains(last.AssistantResponseMessage.Content, "unlikely query") { + t.Fatalf("empty search missing query in folded summary: %q", last.AssistantResponseMessage.Content) + } + if !strings.Contains(last.AssistantResponseMessage.Content, "No results found.") { + t.Fatalf("empty search missing No results found text: %q", last.AssistantResponseMessage.Content) + } + if len(last.AssistantResponseMessage.ToolUses) != 1 || last.AssistantResponseMessage.ToolUses[0].ToolUseID != "toolu_bash" { + t.Fatalf("client tool uses = %+v, want only Bash", last.AssistantResponseMessage.ToolUses) + } +} + +func TestSealClientVisibleWebSearchContentMatchesContinuation(t *testing.T) { + globalSessionStore = newSessionStore() + const api = "key-ws-mixed" + const model = "claude-sonnet-4.5" + inbound := []ClaudeMessage{{Role: "user", Content: "search and then run bash"}} + conv, cont, _ := resolveSessionIDs(api, MapModel(model), inbound) + + content := []map[string]interface{}{ + {"type": "text", "text": "I searched first."}, + { + "type": "server_tool_use", + "id": "srvtoolu_1", + "name": "web_search", + "input": map[string]interface{}{"query": "golang generics"}, + }, + { + "type": "web_search_tool_result", + "content": []interface{}{ + map[string]interface{}{ + "type": "web_search_result", + "title": "Generics", + "url": "https://go.dev", + "encrypted_content": "Go 1.18 introduced generics", + }, + }, + }, + { + "type": "tool_use", + "id": "toolu_bash", + "name": "Bash", + "input": map[string]interface{}{"command": "ls"}, + }, + } + // Old path: seal the raw upstream tool_use shape. That must not match the + // client-visible continuation that stores server_tool_use blocks. + sealClaudeSession(api, model, conv, cont, inbound, "I searched first.", "", + []KiroToolUse{ + {ToolUseID: "toolu_ws", Name: "web_search", Input: map[string]interface{}{"query": "golang generics"}}, + {ToolUseID: "toolu_bash", Name: "Bash", Input: map[string]interface{}{"command": "ls"}}, + }, "", true) + turn2 := []ClaudeMessage{ + inbound[0], + {Role: "assistant", Content: content}, + {Role: "user", Content: []interface{}{ + map[string]interface{}{"type": "tool_result", "tool_use_id": "toolu_bash", "content": "a.go"}, + }}, + } + if _, _, reused := resolveSessionIDs(api, MapModel(model), turn2); reused { + t.Fatal("raw upstream tool shape must not match client-visible server_tool_use continuation") + } + + // Target path: seal the exact client-visible content. Continuation must reuse. + globalSessionStore = newSessionStore() + conv, cont, _ = resolveSessionIDs(api, MapModel(model), inbound) + sealClaudeSessionFromContent(api, model, conv, cont, inbound, content) + gotConv, gotCont, reused := resolveSessionIDs(api, MapModel(model), turn2) + if !reused { + t.Fatal("client-visible flush content must seal the continuation chain") + } + if gotConv != conv || gotCont != cont { + t.Fatalf("ids = %s/%s, want %s/%s", gotConv, gotCont, conv, cont) + } +} + +// Intermediate search-only rounds seal the raw upstream tool shape so the next +// loop iteration can reuse the same conversation / continuation IDs. The final +// client-facing flush still seals server_tool_use content separately. +func TestIntermediateWebSearchRoundSealReusesNextLoopIDs(t *testing.T) { + globalSessionStore = newSessionStore() + globalReasoningStore = newReasoningStore() + + const api = "key-ws-intermediate" + const model = "claude-sonnet-4.5" + inbound := []ClaudeMessage{{Role: "user", Content: "search golang generics"}} + conv, cont, reused := resolveSessionIDs(api, MapModel(model), inbound) + if reused { + t.Fatal("first web-search round must mint fresh ids") + } + + round := &webSearchRoundOutcome{ + text: "Looking it up.", + toolUses: []KiroToolUse{{ + ToolUseID: "toolu_ws_1", + Name: webSearchToolName, + Input: map[string]interface{}{"query": "golang generics"}, + }}, + convID: conv, + contID: cont, + } + // Same seal the production intermediate path performs after a successful + // upstream round, before appendSearchRound mutates the working transcript. + stamp := &reasoningCapture{sig: "sig-ws-1"} + stamp.sealIfPresent(api, MapModel(model), conv, "search golang generics", round.text, round.toolUses, "hidden search plan") + sealClaudeSession(api, model, conv, cont, inbound, round.text, "", round.toolUses, "", true) + + working := &ClaudeRequest{Model: model, Messages: append([]ClaudeMessage(nil), inbound...)} + presentation := make([]map[string]interface{}, 0) + snippet := "Go 1.18 introduced generics" + searched := []*WebSearchResults{{ + Results: []WebSearchResult{{Title: "Generics", URL: "https://go.dev", Snippet: &snippet}}, + }} + appendSearchRound(working, round, searched, &presentation) + + gotConv, gotCont, reusedNext := resolveSessionIDs(api, MapModel(model), working.Messages) + if !reusedNext { + t.Fatal("next loop iteration must reuse ids after intermediate raw-tool seal") + } + if gotConv != conv || gotCont != cont { + t.Fatalf("next-loop ids = %s/%s, want %s/%s", gotConv, gotCont, conv, cont) + } + + // Final client-visible flush still needs its own seal; raw intermediate seal + // must not accidentally match a server_tool_use continuation from the client. + content := buildFlushContent(presentation, "done", nil, nil) + clientTurn := []ClaudeMessage{ + inbound[0], + {Role: "assistant", Content: content}, + {Role: "user", Content: "thanks"}, + } + if _, _, reusedClient := resolveSessionIDs(api, MapModel(model), clientTurn); reusedClient { + t.Fatal("intermediate raw-tool seal must not match client-visible server_tool_use chain") + } +}