diff --git a/internal/runtime/executor/claude_executor_beta_policy_test.go b/internal/runtime/executor/claude_executor_beta_policy_test.go index b7dd7a26a..957186e4b 100644 --- a/internal/runtime/executor/claude_executor_beta_policy_test.go +++ b/internal/runtime/executor/claude_executor_beta_policy_test.go @@ -48,10 +48,11 @@ func TestApplyClaudeHeaders_ConfirmedClientKeepsOAuthCredentialBetas(t *testing. if parts[len(parts)-1] != claudeExtendedCacheTTLBeta { t.Fatalf("Anthropic-Beta = %q, want OAuth cache trailer %s", got, claudeExtendedCacheTTLBeta) } - for _, stale := range []string{"advisor-tool-2026-03-01", "cache-diagnosis-2026-04-07"} { - if strings.Contains(got, stale) { - t.Fatalf("Anthropic-Beta = %q, contains stale OAuth beta %s", got, stale) - } + if strings.Contains(got, "advisor-tool-2026-03-01") { + t.Fatalf("Anthropic-Beta = %q, contains stale OAuth tool beta", got) + } + if strings.Contains(got, claudeCacheDiagnosisBeta) { + t.Fatalf("Anthropic-Beta = %q, contains %s without a diagnostics body", got, claudeCacheDiagnosisBeta) } // The caller's own betas survive the restoration. for _, want := range []string{"interleaved-thinking-2025-05-14", claudeEffortBeta} { @@ -221,8 +222,24 @@ func TestApplyClaudeHeaders_FastModePrecedesOAuthTrailer(t *testing.T) { if parts[len(parts)-2] != claudeFastModeBeta { t.Fatalf("Anthropic-Beta = %q, want %s before the OAuth cache trailer", got, claudeFastModeBeta) } - if strings.Contains(got, "cache-diagnosis-2026-04-07") { - t.Fatalf("Anthropic-Beta = %q, contains stale cache diagnosis beta", got) + if strings.Contains(got, claudeCacheDiagnosisBeta) { + t.Fatalf("Anthropic-Beta = %q, contains %s without a diagnostics body", got, claudeCacheDiagnosisBeta) + } +} + +func TestApplyClaudeHeaders_DiagnosticsBetaFollowsBodyInNativeOrder(t *testing.T) { + for _, stream := range []bool{false, true} { + req := newClaudeHeaderTestRequest(t, nil) + body := []byte(`{"model":"claude-opus-5","diagnostics":{"previous_message_id":null}}`) + if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, stream, nil, + body, nil, nil, false); err != nil { + t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err) + } + got := req.Header.Get("Anthropic-Beta") + wantTrailer := claudeExtendedCacheTTLBeta + "," + claudeCacheDiagnosisBeta + if !strings.HasSuffix(got, wantTrailer) { + t.Fatalf("stream=%v: Anthropic-Beta = %q, want native diagnostics trailer %q", stream, got, wantTrailer) + } } } diff --git a/internal/runtime/executor/claude_executor_diagnostics_test.go b/internal/runtime/executor/claude_executor_diagnostics_test.go index 92995e559..891a9c1b8 100644 --- a/internal/runtime/executor/claude_executor_diagnostics_test.go +++ b/internal/runtime/executor/claude_executor_diagnostics_test.go @@ -41,6 +41,7 @@ func TestInjectClaudeDiagnosticsMatchesNativeFieldOrderAndContinuity(t *testing. func TestClaudeExecutorDiagnosticsAdvancesAfterSuccessfulResponse(t *testing.T) { var previousValues []gjson.Result + var betaValues []string call := 0 transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { body, errRead := io.ReadAll(req.Body) @@ -48,6 +49,11 @@ func TestClaudeExecutorDiagnosticsAdvancesAfterSuccessfulResponse(t *testing.T) t.Fatal(errRead) } previousValues = append(previousValues, gjson.GetBytes(body, "diagnostics.previous_message_id")) + betas := req.Header.Get("Anthropic-Beta") + if betas == "" { + betas = strings.Join(req.Header["anthropic-beta"], ",") + } + betaValues = append(betaValues, betas) call++ response := `{"id":"msg_diagnostics_` + string(rune('0'+call)) + `","type":"message","model":"claude-opus-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}` return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(response)), Request: req}, nil @@ -83,6 +89,12 @@ func TestClaudeExecutorDiagnosticsAdvancesAfterSuccessfulResponse(t *testing.T) if got := previousValues[1].String(); got != "msg_diagnostics_1" { t.Fatalf("second diagnostics previous_message_id = %q, want first upstream response ID", got) } + wantTrailer := claudeExtendedCacheTTLBeta + "," + claudeCacheDiagnosisBeta + for turn, betas := range betaValues { + if !strings.HasSuffix(betas, wantTrailer) { + t.Fatalf("turn %d Anthropic-Beta = %q, want native diagnostics trailer %q", turn+1, betas, wantTrailer) + } + } } func TestClaudeMessageIDFromSSECommitsOnlyCompletedMessage(t *testing.T) { diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index e5be185f1..7ee6bd25d 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -42,6 +42,7 @@ const ( claudeFallbackCreditBeta = "fallback-credit-2026-06-01" claudeStructuredOutputsBeta = "structured-outputs-2025-12-15" claudeExtendedCacheTTLBeta = "extended-cache-ttl-2025-04-11" + claudeCacheDiagnosisBeta = "cache-diagnosis-2026-04-07" ) // claudeCodeCLIConstantBetas are the betas Claude Code 2.1.220 sends on every @@ -91,11 +92,12 @@ var claudeCodeTrailingBetas = []string{ // 13 fallback-credit-2026-06-01 // 14 fast-mode-2026-02-01 speed:fast requests only // 15 extended-cache-ttl-2025-04-11 OAuth credentials only +// 16 cache-diagnosis-2026-04-07 requests with diagnostics only // // An empty body keeps the optimistic role=system default, matching the cloaking // policy for unknown and future model IDs. func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) string { - betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+6) + betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+7) betas = append(betas, claudeCodeBeta) if oauthToken { betas = append(betas, claudeOAuthBeta) @@ -125,6 +127,9 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) if oauthToken { betas = append(betas, claudeExtendedCacheTTLBeta) } + if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.IsObject() { + betas = append(betas, claudeCacheDiagnosisBeta) + } return strings.Join(betas, ",") }