diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index 739bfd0cbd..25b03d8b6b 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -253,12 +253,16 @@ func (r stepResult) toResponseMessages() []fantasy.Message { }) case fantasy.ContentTypeReasoning: reasoning, ok := fantasy.AsContentType[fantasy.ReasoningContent](c) - if !ok || strings.TrimSpace(reasoning.Text) == "" { + if !ok { + continue + } + opts := fantasy.ProviderOptions(reasoning.ProviderMetadata) + if strings.TrimSpace(reasoning.Text) == "" && !chatsanitize.HasAnthropicSignedReasoningOptions(opts) { continue } assistantParts = append(assistantParts, fantasy.ReasoningPart{ Text: reasoning.Text, - ProviderOptions: fantasy.ProviderOptions(reasoning.ProviderMetadata), + ProviderOptions: opts, }) case fantasy.ContentTypeToolCall: toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](c) @@ -418,9 +422,13 @@ func Run(ctx context.Context, opts RunOptions) error { } } var prepared []fantasy.Message - messages, prepared = prepareMessagesForRequest( + var prepareErr error + messages, prepared, prepareErr = prepareMessagesForRequest( ctx, opts, messages, provider, modelName, step, totalSteps, ) + if prepareErr != nil { + return xerrors.Errorf("prepare prompt: %w", prepareErr) + } opts.Metrics.MessageCount.WithLabelValues(provider, modelName).Observe(float64(len(prepared))) opts.Metrics.PromptSizeBytes.WithLabelValues(provider, modelName).Observe(float64(EstimatePromptSize(prepared))) @@ -437,8 +445,12 @@ func Run(ctx context.Context, opts RunOptions) error { } var result stepResult + var retryPrepareErr error stepCtx := chatdebug.ReuseStep(ctx) err := chatretry.Retry(stepCtx, func(retryCtx context.Context) error { + if retryPrepareErr != nil { + return retryPrepareErr + } attempt, streamErr := guardedStream( retryCtx, provider, @@ -497,9 +509,21 @@ func Run(ctx context.Context, opts RunOptions) error { // Reloaded history replaces the prompt prepared before // the failed attempt, so run the same preparation // pipeline used by normal provider requests. - messages, call.Prompt = prepareMessagesForRequest( + var ( + reloadedCanonical []fantasy.Message + retryPrompt []fantasy.Message + prepareErr error + ) + call.Prompt = nil + reloadedCanonical, retryPrompt, prepareErr = prepareMessagesForRequest( ctx, opts, reloaded, provider, modelName, step, totalSteps, ) + if prepareErr != nil { + retryPrepareErr = prepareErr + } else { + messages = reloadedCanonical + call.Prompt = retryPrompt + } } } } @@ -512,6 +536,9 @@ func Run(ctx context.Context, opts RunOptions) error { persistInterruptedStep(ctx, opts, &result) return ErrInterrupted } + if retryPrepareErr != nil && errors.Is(err, retryPrepareErr) { + return xerrors.Errorf("prepare prompt: %w", err) + } return xerrors.Errorf("stream response: %w", err) } @@ -693,7 +720,8 @@ func Run(ctx context.Context, opts RunOptions) error { // prepareMessagesForRequest applies the prompt preparation pipeline used // immediately before sending messages to a provider. It returns the // possibly updated canonical messages and an independent provider-ready -// prompt. +// prompt. When preparation fails, the prompt result is nil and err is the +// terminal prompt-preparation failure. func prepareMessagesForRequest( ctx context.Context, opts RunOptions, @@ -702,7 +730,7 @@ func prepareMessagesForRequest( modelName string, step int, totalSteps int, -) (canonical []fantasy.Message, prompt []fantasy.Message) { +) (canonical []fantasy.Message, prompt []fantasy.Message, err error) { canonical = messages if opts.PrepareMessages != nil { if updated := opts.PrepareMessages(canonical); updated != nil { @@ -718,13 +746,26 @@ func prepareMessagesForRequest( slog.F("step_index", step), slog.F("total_steps", totalSteps), ) - prompt = chatsanitize.ApplyAnthropicProviderToolGuard( + prompt, err = chatsanitize.ApplyAnthropicProviderToolGuard( ctx, opts.Logger, provider, modelName, prompt, ) + if err != nil { + err = chaterror.WithClassification( + xerrors.Errorf("apply anthropic provider tool guard: %w", err), + chaterror.ClassifiedError{ + Message: "The chat continuation failed due to an internal state mismatch. This is not a configuration or billing issue. Start a new chat to continue.", + Detail: "Anthropic replay diagnostic: match=provider_tool_guard_postcondition_failed.", + Kind: codersdk.ChatErrorKindGeneric, + Provider: provider, + Retryable: false, + }, + ) + return canonical, nil, err + } if shouldApplyAnthropicPromptCaching(opts.Model) { addAnthropicPromptCaching(prompt) } - return canonical, prompt + return canonical, prompt, nil } // guardedAttempt owns an attempt-scoped context and startup guard @@ -881,14 +922,16 @@ func processStepStream( case fantasy.StreamPartTypeReasoningDelta: if active, exists := activeReasoningContent[part.ID]; exists { active.text += part.Delta - active.options = part.ProviderMetadata + if len(part.ProviderMetadata) > 0 { + active.options = part.ProviderMetadata + } activeReasoningContent[part.ID] = active } publishMessagePart(codersdk.ChatMessageRoleAssistant, codersdk.ChatMessageReasoning(part.Delta)) case fantasy.StreamPartTypeReasoningEnd: if active, exists := activeReasoningContent[part.ID]; exists { - if part.ProviderMetadata != nil { + if len(part.ProviderMetadata) > 0 { active.options = part.ProviderMetadata } content := fantasy.ReasoningContent{ @@ -1564,12 +1607,13 @@ func flushActiveState( // Flush partial reasoning content. for _, rs := range activeReasoning { - if rs.text != "" { - result.content = append(result.content, fantasy.ReasoningContent{ - Text: rs.text, - ProviderMetadata: rs.options, - }) + if rs.text == "" && !chatsanitize.HasAnthropicSignedReasoningOptions(fantasy.ProviderOptions(rs.options)) { + continue } + result.content = append(result.content, fantasy.ReasoningContent{ + Text: rs.text, + ProviderMetadata: rs.options, + }) } // Flush in-progress tool calls. These haven't received a @@ -1599,8 +1643,9 @@ func flushActiveState( } // persistInterruptedStep saves durable content from a partial stream. -// Provider-executed calls without results are removed because their -// result metadata cannot be synthesized safely. +// Provider-executed calls without results are removed because their result +// metadata cannot be synthesized safely, except when removal would mutate +// signed Anthropic replay state. func persistInterruptedStep( ctx context.Context, opts RunOptions, diff --git a/coderd/x/chatd/chatloop/chatloop_internal_test.go b/coderd/x/chatd/chatloop/chatloop_internal_test.go index d45b551b62..316cd75fb7 100644 --- a/coderd/x/chatd/chatloop/chatloop_internal_test.go +++ b/coderd/x/chatd/chatloop/chatloop_internal_test.go @@ -12,8 +12,11 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chatopenai" "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" ) func TestRun_ChainBrokenRecovers(t *testing.T) { @@ -418,6 +421,64 @@ func TestRun_ChainBrokenReloadFailureStillClearsChain(t *testing.T) { requireTextPrompt(t, secondPrompt, "prepared") } +func TestRun_ChainBrokenRecoveryPrepareFailureReturnsPreparePhaseError(t *testing.T) { + t.Parallel() + + var streamCalls int + model := &chattest.FakeModel{ + ProviderName: fantasyanthropic.Name, + ModelName: "claude-test", + StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { + streamCalls++ + return nil, xerrors.New(chainBrokenErrorMessage) + }, + } + + reloadCalls := 0 + err := Run(context.Background(), RunOptions{ + Model: model, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + MaxSteps: 1, + ContextLimitFallback: 4096, + Messages: []fantasy.Message{ + textMessage(fantasy.MessageRoleUser, "chain-filtered"), + }, + ProviderOptions: chainModeProviderOptions("resp_poisoned"), + PersistStep: func(_ context.Context, _ PersistedStep) error { + return nil + }, + DisableChainMode: func() {}, + ReloadMessages: func(_ context.Context) ([]fantasy.Message, error) { + reloadCalls++ + return []fantasy.Message{ + textMessage(fantasy.MessageRoleUser, "search"), + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{ProviderOptions: fantasy.ProviderOptions{fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{RedactedData: "redacted-payload"}}}, + fantasy.ToolCallPart{ToolCallID: "ws-orphan", ToolName: "web_search", Input: `{"query":"coder"}`, ProviderExecuted: true}, + fantasy.TextPart{Text: "partial"}, + }, + }, + textMessage(fantasy.MessageRoleUser, "continue"), + }, nil + }, + }) + + require.Error(t, err) + require.Equal(t, 1, reloadCalls) + require.Equal(t, 1, streamCalls, "retry must fail before issuing another provider call") + require.ErrorContains(t, err, "prepare prompt:") + require.NotContains(t, err.Error(), "stream response:") + require.Equal(t, chaterror.ClassifiedError{ + Message: "The chat continuation failed due to an internal state mismatch. This is not a configuration or billing issue. Start a new chat to continue.", + Detail: "Anthropic replay diagnostic: match=provider_tool_guard_postcondition_failed.", + Kind: codersdk.ChatErrorKindGeneric, + Provider: fantasyanthropic.Name, + Retryable: false, + }, chaterror.Classify(err)) +} + func TestRun_ChainBrokenWithoutChainModeIsSafe(t *testing.T) { t.Parallel() @@ -512,6 +573,132 @@ func TestRun_NonChainBrokenRetryDoesNotTouchChainState(t *testing.T) { ) } +func TestProcessStepStreamPreservesReasoningMetadataAcrossNilDelta(t *testing.T) { + t.Parallel() + + stream := iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) { + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeReasoningStart, ID: "0"}) + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeReasoningDelta, ID: "0", Delta: "thinking"}) + yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeReasoningDelta, + ID: "0", + ProviderMetadata: fantasy.ProviderMetadata{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + Signature: "sig", + }, + }, + }) + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeReasoningDelta, ID: "0", ProviderMetadata: fantasy.ProviderMetadata{}}) + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeReasoningDelta, ID: "0"}) + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeReasoningEnd, ID: "0", ProviderMetadata: fantasy.ProviderMetadata{}}) + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop}) + }) + + result, err := processStepStream(context.Background(), stream, func(codersdk.ChatMessageRole, codersdk.ChatMessagePart) {}) + require.NoError(t, err) + require.Len(t, result.content, 1) + reasoning, ok := fantasy.AsContentType[fantasy.ReasoningContent](result.content[0]) + require.True(t, ok) + require.Equal(t, "thinking", reasoning.Text) + metadata := fantasyanthropic.GetReasoningMetadata(fantasy.ProviderOptions(reasoning.ProviderMetadata)) + require.NotNil(t, metadata) + require.Equal(t, "sig", metadata.Signature) +} + +func TestProcessStepStreamPersistsRedactedThinkingOnEnd(t *testing.T) { + t.Parallel() + + stream := iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) { + reasoningMetadata := fantasy.ProviderMetadata{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + RedactedData: "redacted-payload", + }, + } + yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeReasoningStart, + ID: "0", + ProviderMetadata: reasoningMetadata, + }) + yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeReasoningEnd, + ID: "0", + ProviderMetadata: reasoningMetadata, + }) + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextStart, ID: "1"}) + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, ID: "1", Delta: "done"}) + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextEnd, ID: "1"}) + yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop}) + }) + + result, err := processStepStream(context.Background(), stream, func(codersdk.ChatMessageRole, codersdk.ChatMessagePart) {}) + require.NoError(t, err) + require.Len(t, result.content, 2) + reasoning, ok := fantasy.AsContentType[fantasy.ReasoningContent](result.content[0]) + require.True(t, ok) + require.Empty(t, reasoning.Text) + metadata := fantasyanthropic.GetReasoningMetadata(fantasy.ProviderOptions(reasoning.ProviderMetadata)) + require.NotNil(t, metadata) + require.Equal(t, "redacted-payload", metadata.RedactedData) +} + +func TestStepResultToResponseMessagesPreservesEmptySignedReasoning(t *testing.T) { + t.Parallel() + + result := stepResult{ + content: []fantasy.Content{ + fantasy.ReasoningContent{ + ProviderMetadata: fantasy.ProviderMetadata{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + RedactedData: "redacted-payload", + }, + }, + }, + fantasy.TextContent{Text: "done"}, + }, + } + + messages := result.toResponseMessages() + + require.Len(t, messages, 1) + require.Len(t, messages[0].Content, 2) + reasoning, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](messages[0].Content[0]) + require.True(t, ok) + require.Empty(t, reasoning.Text) + metadata := fantasyanthropic.GetReasoningMetadata(reasoning.ProviderOptions) + require.NotNil(t, metadata) + require.Equal(t, "redacted-payload", metadata.RedactedData) +} + +func TestFlushActiveStatePreservesEmptySignedReasoning(t *testing.T) { + t.Parallel() + + result := &stepResult{} + flushActiveState( + result, + map[string]string{}, + map[string]reasoningState{ + "signed": { + options: fantasy.ProviderMetadata{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + RedactedData: "redacted-payload", + }, + }, + }, + "empty": {}, + }, + map[string]*fantasy.ToolCallContent{}, + map[string]string{}, + ) + + require.Len(t, result.content, 1) + reasoning, ok := fantasy.AsContentType[fantasy.ReasoningContent](result.content[0]) + require.True(t, ok) + require.Empty(t, reasoning.Text) + metadata := fantasyanthropic.GetReasoningMetadata(fantasy.ProviderOptions(reasoning.ProviderMetadata)) + require.NotNil(t, metadata) + require.Equal(t, "redacted-payload", metadata.RedactedData) +} + // chainBrokenError is what OpenAI returns when previous_response_id // points at a response it does not have stored. const chainBrokenErrorMessage = "Previous response with id 'resp_abc' not found." diff --git a/coderd/x/chatd/chatloop/chatloop_test.go b/coderd/x/chatd/chatloop/chatloop_test.go index daa4588dcc..be7d766430 100644 --- a/coderd/x/chatd/chatloop/chatloop_test.go +++ b/coderd/x/chatd/chatloop/chatloop_test.go @@ -3632,7 +3632,7 @@ func TestRun_AnthropicProviderToolPreRequestGuard(t *testing.T) { t.Run("direct guard textifies orphaned provider result", func(t *testing.T) { t.Parallel() - guarded := chatsanitize.ApplyAnthropicProviderToolGuard( + guarded, err := chatsanitize.ApplyAnthropicProviderToolGuard( context.Background(), slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), fantasyanthropic.Name, @@ -3651,6 +3651,7 @@ func TestRun_AnthropicProviderToolPreRequestGuard(t *testing.T) { }, }, ) + require.NoError(t, err) requireNoProviderExecutedToolResultPrompt(t, guarded) requireAnthropicProviderToolPromptSafe(t, guarded) @@ -3670,13 +3671,14 @@ func TestRun_AnthropicProviderToolPreRequestGuard(t *testing.T) { content := []fantasy.MessagePart{fantasy.TextPart{Text: "keep"}} content = append(content, providerPair("ws-one")...) content = append(content, providerPair("ws-two")...) - guarded := chatsanitize.ApplyAnthropicProviderToolGuard( + guarded, err := chatsanitize.ApplyAnthropicProviderToolGuard( context.Background(), slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), fantasyanthropic.Name, "claude-test", []fantasy.Message{{Role: fantasy.MessageRoleAssistant, Content: content}}, ) + require.NoError(t, err) requireAnthropicProviderToolPromptSafe(t, guarded) require.Len(t, guarded, 1) @@ -3696,13 +3698,14 @@ func TestRun_AnthropicProviderToolPreRequestGuard(t *testing.T) { Content: providerPair("ws-other-provider"), }, } - guarded := chatsanitize.ApplyAnthropicProviderToolGuard( + guarded, err := chatsanitize.ApplyAnthropicProviderToolGuard( context.Background(), slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), "fake", "fake-model", prompt, ) + require.NoError(t, err) require.Equal(t, prompt, guarded) }) @@ -3712,7 +3715,7 @@ func TestRun_AnthropicProviderToolPreRequestGuard(t *testing.T) { logSink := testutil.NewFakeSink(t) logger := logSink.Logger() logPair := providerPair("ws-log") - guarded := chatsanitize.ApplyAnthropicProviderToolGuard( + guarded, err := chatsanitize.ApplyAnthropicProviderToolGuard( context.Background(), logger, fantasyanthropic.Name, @@ -3727,6 +3730,7 @@ func TestRun_AnthropicProviderToolPreRequestGuard(t *testing.T) { }, }, ) + require.NoError(t, err) requireNoProviderExecutedToolCallPrompt(t, guarded) requireNoProviderExecutedToolResultPrompt(t, guarded) @@ -3740,6 +3744,60 @@ func TestRun_AnthropicProviderToolPreRequestGuard(t *testing.T) { require.Equal(t, 1, requireLogField(t, entries[0], "removed_tool_calls")) require.Equal(t, 1, requireLogField(t, entries[0], "removed_tool_results")) }) + t.Run("run fails before provider call when latest signed assistant is unreplayable", func(t *testing.T) { + t.Parallel() + + streamCalls := 0 + model := &chattest.FakeModel{ + ProviderName: fantasyanthropic.Name, + ModelName: "claude-test", + StreamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) { + streamCalls++ + return finishingStream(), nil + }, + } + + err := Run(context.Background(), RunOptions{ + Model: model, + Messages: []fantasy.Message{ + textMessage(fantasy.MessageRoleUser, "search"), + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{ + ProviderOptions: fantasy.ProviderOptions{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + RedactedData: "redacted-payload", + }, + }, + }, + fantasy.ToolCallPart{ + ToolCallID: "ws-orphan", + ToolName: "web_search", + Input: `{"query":"coder"}`, + ProviderExecuted: true, + }, + fantasy.TextPart{Text: "partial"}, + }, + }, + textMessage(fantasy.MessageRoleUser, "continue"), + }, + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + MaxSteps: 1, + PersistStep: func(_ context.Context, _ PersistedStep) error { + return nil + }, + }) + require.Error(t, err) + require.Zero(t, streamCalls) + require.Equal(t, chaterror.ClassifiedError{ + Message: "The chat continuation failed due to an internal state mismatch. This is not a configuration or billing issue. Start a new chat to continue.", + Detail: "Anthropic replay diagnostic: match=provider_tool_guard_postcondition_failed.", + Kind: codersdk.ChatErrorKindGeneric, + Provider: "anthropic", + Retryable: false, + }, chaterror.Classify(err)) + }) } // TestRun_PersistStepInterruptedFallback verifies that when the normal diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index b7681a6b44..6989a4ba51 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -17,6 +17,7 @@ import ( "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/x/chatd/chatsanitize" "github.com/coder/coder/v2/coderd/x/chatd/chattool" "github.com/coder/coder/v2/codersdk" ) @@ -1497,13 +1498,13 @@ func partsToMessageParts( ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata), }) case codersdk.ChatMessagePartTypeReasoning: - // Same guard as text parts above. - if strings.TrimSpace(part.Text) == "" { + opts := providerMetadataToOptions(logger, part.ProviderMetadata) + if strings.TrimSpace(part.Text) == "" && !chatsanitize.HasAnthropicSignedReasoningOptions(opts) { continue } result = append(result, fantasy.ReasoningPart{ Text: part.Text, - ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata), + ProviderOptions: opts, }) case codersdk.ChatMessagePartTypeToolCall: result = append(result, fantasy.ToolCallPart{ diff --git a/coderd/x/chatd/chatprompt/chatprompt_test.go b/coderd/x/chatd/chatprompt/chatprompt_test.go index c9180eb7fe..acccf30f11 100644 --- a/coderd/x/chatd/chatprompt/chatprompt_test.go +++ b/coderd/x/chatd/chatprompt/chatprompt_test.go @@ -38,6 +38,149 @@ func testMsg(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) database. } } +func TestConvertMessagesWithFilesPreservesEmptyRedactedReasoning(t *testing.T) { + t.Parallel() + + metadata, err := json.Marshal(fantasy.ProviderMetadata{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + RedactedData: "redacted-payload", + }, + }) + require.NoError(t, err) + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeReasoning, + ProviderMetadata: metadata, + }, + codersdk.ChatMessageText("done"), + }) + require.NoError(t, err) + + prompt, err := chatprompt.ConvertMessagesWithFiles(context.Background(), []database.ChatMessage{ + { + Role: database.ChatMessageRoleAssistant, + Visibility: database.ChatMessageVisibilityBoth, + Content: content, + ContentVersion: chatprompt.CurrentContentVersion, + }, + }, nil, slogtest.Make(t, nil)) + require.NoError(t, err) + require.Len(t, prompt, 1) + require.Len(t, prompt[0].Content, 2) + + reasoning, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](prompt[0].Content[0]) + require.True(t, ok) + require.Empty(t, reasoning.Text) + reasoningMetadata := fantasyanthropic.GetReasoningMetadata(reasoning.ProviderOptions) + require.NotNil(t, reasoningMetadata) + require.Equal(t, "redacted-payload", reasoningMetadata.RedactedData) +} + +func TestConvertMessagesWithFilesRoundTripsAnthropicInterleavedWebSearch(t *testing.T) { + t.Parallel() + + content := []fantasy.Content{ + fantasy.ReasoningContent{ + Text: "thinking one", + ProviderMetadata: fantasy.ProviderMetadata{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + Signature: "sig-1", + }, + }, + }, + fantasy.ToolCallContent{ + ToolCallID: "srv-1", + ToolName: "web_search", + Input: `{"query":"coder"}`, + ProviderExecuted: true, + }, + fantasy.ToolResultContent{ + ToolCallID: "srv-1", + ToolName: "web_search", + ProviderExecuted: true, + ProviderMetadata: fantasy.ProviderMetadata{ + fantasyanthropic.Name: &fantasyanthropic.WebSearchResultMetadata{ + Results: []fantasyanthropic.WebSearchResultItem{ + { + URL: "https://coder.com", + Title: "Coder", + EncryptedContent: "encrypted-1", + }, + }, + }, + }, + }, + fantasy.ReasoningContent{ + ProviderMetadata: fantasy.ProviderMetadata{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + RedactedData: "redacted-payload", + }, + }, + }, + fantasy.TextContent{Text: "answer"}, + } + storedParts := make([]codersdk.ChatMessagePart, 0, len(content)) + for _, block := range content { + storedParts = append(storedParts, chatprompt.PartFromContent(block)) + } + + storedContent, err := chatprompt.MarshalParts(storedParts) + require.NoError(t, err) + parsedParts, err := chatprompt.ParseContent(database.ChatMessage{ + Role: database.ChatMessageRoleAssistant, + Content: storedContent, + ContentVersion: chatprompt.CurrentContentVersion, + }) + require.NoError(t, err) + require.Len(t, parsedParts, 5) + require.Equal(t, codersdk.ChatMessagePartTypeReasoning, parsedParts[0].Type) + require.Equal(t, codersdk.ChatMessagePartTypeToolCall, parsedParts[1].Type) + require.Equal(t, codersdk.ChatMessagePartTypeToolResult, parsedParts[2].Type) + require.Equal(t, codersdk.ChatMessagePartTypeReasoning, parsedParts[3].Type) + require.Equal(t, codersdk.ChatMessagePartTypeText, parsedParts[4].Type) + + prompt, err := chatprompt.ConvertMessagesWithFiles(context.Background(), []database.ChatMessage{ + { + Role: database.ChatMessageRoleAssistant, + Visibility: database.ChatMessageVisibilityBoth, + Content: storedContent, + ContentVersion: chatprompt.CurrentContentVersion, + }, + }, nil, slogtest.Make(t, nil)) + require.NoError(t, err) + require.Len(t, prompt, 1) + require.Len(t, prompt[0].Content, 5) + + firstReasoning, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](prompt[0].Content[0]) + require.True(t, ok) + require.Equal(t, "thinking one", firstReasoning.Text) + require.Equal(t, "sig-1", fantasyanthropic.GetReasoningMetadata(firstReasoning.ProviderOptions).Signature) + + call, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](prompt[0].Content[1]) + require.True(t, ok) + require.True(t, call.ProviderExecuted) + require.Equal(t, "srv-1", call.ToolCallID) + require.Equal(t, "web_search", call.ToolName) + require.JSONEq(t, `{"query":"coder"}`, call.Input) + + result, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](prompt[0].Content[2]) + require.True(t, ok) + require.True(t, result.ProviderExecuted) + resultMetadata := result.ProviderOptions[fantasyanthropic.Name].(*fantasyanthropic.WebSearchResultMetadata) + require.Equal(t, "encrypted-1", resultMetadata.Results[0].EncryptedContent) + + redactedReasoning, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](prompt[0].Content[3]) + require.True(t, ok) + require.Empty(t, redactedReasoning.Text) + reasoningMetadata := fantasyanthropic.GetReasoningMetadata(redactedReasoning.ProviderOptions) + require.NotNil(t, reasoningMetadata) + require.Equal(t, "redacted-payload", reasoningMetadata.RedactedData) + + text, ok := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[4]) + require.True(t, ok) + require.Equal(t, "answer", text.Text) +} + // testMsgV1 builds a database.ChatMessage with ContentVersion 1. func testMsgV1(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) database.ChatMessage { return database.ChatMessage{ diff --git a/coderd/x/chatd/chatsanitize/anthropic.go b/coderd/x/chatd/chatsanitize/anthropic.go index 151376a409..098bdf22ce 100644 --- a/coderd/x/chatd/chatsanitize/anthropic.go +++ b/coderd/x/chatd/chatsanitize/anthropic.go @@ -7,12 +7,18 @@ import ( "charm.land/fantasy" fantasyanthropic "charm.land/fantasy/providers/anthropic" + "golang.org/x/xerrors" "cdr.dev/slog/v3" ) const maxAnthropicProviderToolViolationLogDetails = 32 +// Anthropic immutability contract. The latest assistant message containing +// signed or redacted reasoning is an immutable transcript boundary. Helpers +// in this file enforce it via HasAnthropicSignedReasoningOptions, +// latestAssistantMessageIndexWithSignedReasoning, and appendSanitizedMessage. + // supportedAnthropicProviderToolNames is the allowlist of provider-executed // tool names the Anthropic provider in fantasy can currently serialize. var supportedAnthropicProviderToolNames = map[string]struct{}{ @@ -46,6 +52,13 @@ type AnthropicProviderToolHistoryViolation struct { Reason string } +// ErrAnthropicProviderToolPromptUnsafe reports that the pre-request +// guard could not repair provider-executed tool history into a prompt +// shape Anthropic will accept. +var ErrAnthropicProviderToolPromptUnsafe = xerrors.New( + "anthropic prompt still contains invalid provider-executed tool history after guard", +) + // LogAnthropicProviderToolSanitization logs prompt changes made while // removing invalid Anthropic provider-executed tool history. func LogAnthropicProviderToolSanitization( @@ -243,7 +256,15 @@ func SanitizeAnthropicProviderToolHistory( for { // Each pass shrinks the finite part set, so the loop terminates. analysis := analyzeAnthropicProviderToolHistory(current) - if len(analysis.remove) == 0 { + remove := analysis.remove + if immutableIndex := latestAssistantMessageIndexWithSignedReasoning(current); immutableIndex >= 0 { + for key := range remove { + if key.messageIndex == immutableIndex { + delete(remove, key) + } + } + } + if len(remove) == 0 { if !changed { return messages, stats } @@ -259,7 +280,7 @@ func SanitizeAnthropicProviderToolHistory( messageIndex: messageIndex, partIndex: partIndex, } - if _, remove := analysis.remove[key]; remove { + if _, remove := remove[key]; remove { countRemovedAnthropicProviderToolPart(&stats, part) if textPart, ok := AnthropicProviderToolResultTextPart(part); ok { parts = append(parts, textPart) @@ -306,7 +327,8 @@ func SanitizeAnthropicProviderToolStepContent( } // SanitizeAnthropicProviderToolContent removes invalid Anthropic -// provider-executed tool blocks from streamed content. +// provider-executed tool blocks from streamed content when doing so does +// not mutate signed reasoning replay state. func SanitizeAnthropicProviderToolContent( provider string, content []fantasy.Content, @@ -315,6 +337,9 @@ func SanitizeAnthropicProviderToolContent( if provider != fantasyanthropic.Name || len(content) == 0 { return content, stats } + if contentHasAnthropicSignedReasoning(content) { + return content, stats + } partIndexByContentIndex := make([]int, len(content)) for index := range partIndexByContentIndex { @@ -414,6 +439,34 @@ func SanitizeAnthropicProviderToolContent( return out, stats } +func hasAnthropicSignedReasoningMetadata( + metadata *fantasyanthropic.ReasoningOptionMetadata, +) bool { + return metadata != nil && (metadata.Signature != "" || metadata.RedactedData != "") +} + +// HasAnthropicSignedReasoningOptions reports whether provider options contain +// Anthropic reasoning data that must be replayed without mutation. +func HasAnthropicSignedReasoningOptions(options fantasy.ProviderOptions) bool { + return hasAnthropicSignedReasoningMetadata(fantasyanthropic.GetReasoningMetadata(options)) +} + +func contentHasAnthropicSignedReasoning(content []fantasy.Content) bool { + for _, block := range content { + reasoning, ok := fantasy.AsContentType[fantasy.ReasoningContent](block) + if !ok { + continue + } + metadata := fantasyanthropic.GetReasoningMetadata( + fantasy.ProviderOptions(reasoning.ProviderMetadata), + ) + if hasAnthropicSignedReasoningMetadata(metadata) { + return true + } + } + return false +} + // IsAnthropicProviderExecutedToolCall reports whether toolCall is an // Anthropic provider-executed tool call. func IsAnthropicProviderExecutedToolCall( @@ -424,21 +477,23 @@ func IsAnthropicProviderExecutedToolCall( } // ApplyAnthropicProviderToolGuard fail-closes unsafe Anthropic provider-tool -// history immediately before a provider request is issued. +// history immediately before a provider request is issued. It returns a +// sanitized prompt on success, or nil with ErrAnthropicProviderToolPromptUnsafe +// when the prompt still cannot be repaired safely. func ApplyAnthropicProviderToolGuard( ctx context.Context, logger slog.Logger, provider string, modelName string, messages []fantasy.Message, -) []fantasy.Message { +) ([]fantasy.Message, error) { if provider != fantasyanthropic.Name || len(messages) == 0 { - return messages + return messages, nil } violations := ValidateAnthropicProviderToolHistory(messages) if len(violations) == 0 { - return messages + return messages, nil } affectedMessages := messageIndexesFromAnthropicProviderToolViolations( violations, @@ -454,7 +509,7 @@ func ApplyAnthropicProviderToolGuard( len(violations), ) if isSafeAnthropicProviderToolPrompt(guarded) { - return guarded + return guarded, nil } fallbackViolations := ValidateAnthropicProviderToolHistory(guarded) @@ -470,7 +525,7 @@ func ApplyAnthropicProviderToolGuard( slog.F("fallback", true), ) if isSafeAnthropicProviderToolPrompt(guarded) { - return guarded + return guarded, nil } // The guard sanitizer should normally remove every typed provider block it @@ -505,16 +560,6 @@ func ApplyAnthropicProviderToolGuard( guarded, ) stripStats = addAnthropicProviderToolSanitizationStats(stripStats, sanitizeStats) - if !isSafeAnthropicProviderToolPrompt(guarded) { - logger.Error( - ctx, - "anthropic provider tool guard postcondition failed: prompt still unsafe after nuclear strip", - slog.F("phase", "pre_request_guard_postcondition_failed"), - slog.F("tool_type", "provider_executed"), - slog.F("provider", provider), - slog.F("model", modelName), - ) - } } details, truncated := anthropicProviderToolViolationLogDetails( @@ -531,7 +576,40 @@ func ApplyAnthropicProviderToolGuard( slog.F("validation_violation_details", details), slog.F("truncated_violations", truncated), ) - return guarded + + finalViolations := ValidateAnthropicProviderToolHistory(guarded) + if len(finalViolations) == 0 { + return guarded, nil + } + + immutableLatestSignedAssistant := false + if immutableIndex := latestAssistantMessageIndexWithSignedReasoning(guarded); immutableIndex >= 0 { + for _, violation := range finalViolations { + if violation.MessageIndex == immutableIndex { + immutableLatestSignedAssistant = true + break + } + } + } + finalDetails, finalTruncated := anthropicProviderToolViolationLogDetails( + finalViolations, + ) + logger.Error( + ctx, + "anthropic provider tool guard postcondition failed: prompt still unsafe after nuclear strip", + slog.F("phase", "pre_request_guard_postcondition_failed"), + slog.F("tool_type", "provider_executed"), + slog.F("provider", provider), + slog.F("model", modelName), + slog.F("validation_violations", len(finalViolations)), + slog.F("validation_violation_details", finalDetails), + slog.F("truncated_violations", finalTruncated), + slog.F( + "immutable_latest_signed_assistant", + immutableLatestSignedAssistant, + ), + ) + return nil, ErrAnthropicProviderToolPromptUnsafe } type anthropicProviderToolPartKey struct { @@ -539,6 +617,50 @@ type anthropicProviderToolPartKey struct { partIndex int } +// latestAssistantMessageIndexWithSignedReasoning returns the most recent +// assistant message when that message carries signed or redacted Anthropic +// reasoning. Older signed assistant turns were already validated when they +// were the latest replay boundary. If Anthropic ever validates earlier turns +// during replay, this is the single place to revisit that assumption. +func latestAssistantMessageIndexWithSignedReasoning(messages []fantasy.Message) int { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role != fantasy.MessageRoleAssistant { + continue + } + if messageHasAnthropicSignedReasoning(messages[i]) { + return i + } + return -1 + } + return -1 +} + +func messageHasAnthropicSignedReasoning(message fantasy.Message) bool { + for _, part := range message.Content { + reasoning, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](part) + if !ok { + continue + } + metadata := fantasyanthropic.GetReasoningMetadata(reasoning.ProviderOptions) + if hasAnthropicSignedReasoningMetadata(metadata) { + return true + } + } + return false +} + +func excludeImmutableSignedReasoningMessages( + messages []fantasy.Message, + affected map[int]struct{}, +) { + if len(affected) == 0 { + return + } + if immutableIndex := latestAssistantMessageIndexWithSignedReasoning(messages); immutableIndex >= 0 { + delete(affected, immutableIndex) + } +} + type anthropicProviderToolHistoryAnalysis struct { remove map[anthropicProviderToolPartKey]struct{} violations []AnthropicProviderToolHistoryViolation @@ -842,6 +964,10 @@ func sanitizeAnthropicProviderToolGuardMessages( validationViolations int, extraFields ...slog.Field, ) []fantasy.Message { + excludeImmutableSignedReasoningMessages(messages, affectedMessages) + if len(affectedMessages) == 0 { + return messages + } guardPrompt := invalidateProviderExecutedToolCallsInMessages(messages, affectedMessages) // Marking affected provider calls invalid lets the sanitizer remove the // unsafe history while preserving result payloads as plain text. @@ -902,6 +1028,7 @@ func stripAnthropicProviderToolHistoryFromMessages( affectedMessages map[int]struct{}, ) ([]fantasy.Message, AnthropicProviderToolSanitizationStats) { var stats AnthropicProviderToolSanitizationStats + excludeImmutableSignedReasoningMessages(messages, affectedMessages) if len(affectedMessages) == 0 { return messages, stats } @@ -942,6 +1069,14 @@ func appendSanitizedMessage(out []fantasy.Message, msg fantasy.Message) []fantas if len(out) == 0 || out[len(out)-1].Role != msg.Role { return append(out, msg) } + // Refuse to coalesce across an immutable Anthropic turn. Merging would + // reorder parts within the signed message and break replay fidelity. The + // resulting consecutive same-role messages are valid for Anthropic. + crossesImmutableBoundary := messageHasAnthropicSignedReasoning(out[len(out)-1]) || + messageHasAnthropicSignedReasoning(msg) + if crossesImmutableBoundary { + return append(out, msg) + } last := &out[len(out)-1] lastContent := applyMessageProviderOptionsToLastPart(last.Content, last.ProviderOptions) diff --git a/coderd/x/chatd/chatsanitize/anthropic_test.go b/coderd/x/chatd/chatsanitize/anthropic_test.go index 456cfeb7ea..a9a060a0dd 100644 --- a/coderd/x/chatd/chatsanitize/anthropic_test.go +++ b/coderd/x/chatd/chatsanitize/anthropic_test.go @@ -1,6 +1,7 @@ package chatsanitize_test import ( + "context" "testing" "charm.land/fantasy" @@ -8,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "cdr.dev/slog/v3/sloggers/slogtest" "github.com/coder/coder/v2/coderd/x/chatd/chatsanitize" ) @@ -45,6 +47,207 @@ func validWebSearchProviderOptionsForTest() fantasy.ProviderOptions { } } +func providerToolCallPartForSignedReasoningTest(id string) fantasy.ToolCallPart { + return fantasy.ToolCallPart{ + ToolCallID: id, + ToolName: "web_search", + Input: `{"query":"coder"}`, + ProviderExecuted: true, + } +} + +func signedReasoningPartForTest(signature string) fantasy.ReasoningPart { + return fantasy.ReasoningPart{ + Text: "signed thinking", + ProviderOptions: fantasy.ProviderOptions{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + Signature: signature, + }, + }, + } +} + +func redactedReasoningPartForTest(redactedData string) fantasy.ReasoningPart { + return fantasy.ReasoningPart{ + Text: "redacted thinking", + ProviderOptions: fantasy.ProviderOptions{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + RedactedData: redactedData, + }, + }, + } +} + +func latestReasoningAssistantForTest(reasoning fantasy.ReasoningPart) fantasy.Message { + return fantasy.Message{ + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + reasoning, + providerToolCallPartForSignedReasoningTest("srvtoolu_latest_orphan"), + fantasy.TextPart{Text: "answer"}, + }, + } +} + +func priorAssistantWithOrphanForTest() fantasy.Message { + return fantasy.Message{ + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + providerToolCallPartForSignedReasoningTest("srvtoolu_prior_orphan"), + fantasy.TextPart{Text: "prior"}, + }, + } +} + +func sanitizedPriorAssistantForTest() fantasy.Message { + return fantasy.Message{ + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "prior"}, + }, + } +} + +func TestSanitizeAnthropicProviderToolHistoryDoesNotMergeAcrossLatestReasoningAssistant(t *testing.T) { + t.Parallel() + + reasoningVariants := []struct { + name string + part fantasy.ReasoningPart + }{ + {name: "signed", part: signedReasoningPartForTest("sig-latest")}, + {name: "redacted", part: redactedReasoningPartForTest("redacted-latest")}, + } + + for _, reasoningVariant := range reasoningVariants { + t.Run(reasoningVariant.name, func(t *testing.T) { + t.Parallel() + + sanitized, stats := chatsanitize.SanitizeAnthropicProviderToolHistory( + fantasyanthropic.Name, + []fantasy.Message{ + priorAssistantWithOrphanForTest(), + latestReasoningAssistantForTest(reasoningVariant.part), + }, + ) + + require.Equal(t, chatsanitize.AnthropicProviderToolSanitizationStats{RemovedToolCalls: 1}, stats) + require.Equal(t, []fantasy.Message{ + sanitizedPriorAssistantForTest(), + latestReasoningAssistantForTest(reasoningVariant.part), + }, sanitized) + }) + } +} + +func TestApplyAnthropicProviderToolGuardRepairsOlderSignedAssistantWhenLatestAssistantIsUnsigned(t *testing.T) { + t.Parallel() + + reasoningVariants := []struct { + name string + part fantasy.ReasoningPart + }{ + {name: "signed", part: signedReasoningPartForTest("sig-older")}, + {name: "redacted", part: redactedReasoningPartForTest("redacted-older")}, + } + + for _, reasoningVariant := range reasoningVariants { + t.Run(reasoningVariant.name, func(t *testing.T) { + t.Parallel() + guarded, err := chatsanitize.ApplyAnthropicProviderToolGuard( + context.Background(), + slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + fantasyanthropic.Name, + "claude-test", + []fantasy.Message{ + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + reasoningVariant.part, + providerToolCallPartForSignedReasoningTest("srvtoolu_older_orphan"), + fantasy.TextPart{Text: "older"}, + }, + }, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "continue"}}}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "latest unsigned"}}}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "next"}}}, + }, + ) + require.NoError(t, err) + require.Equal(t, []fantasy.Message{ + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + reasoningVariant.part, + fantasy.TextPart{Text: "older"}, + }, + }, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "continue"}}}, + {Role: fantasy.MessageRoleAssistant, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "latest unsigned"}}}, + {Role: fantasy.MessageRoleUser, Content: []fantasy.MessagePart{fantasy.TextPart{Text: "next"}}}, + }, guarded) + }) + } +} + +func TestApplyAnthropicProviderToolGuardDoesNotMergeAcrossLatestReasoningAssistant(t *testing.T) { + t.Parallel() + + reasoningVariants := []struct { + name string + part fantasy.ReasoningPart + }{ + {name: "signed", part: signedReasoningPartForTest("sig-latest")}, + {name: "redacted", part: redactedReasoningPartForTest("redacted-latest")}, + } + + for _, reasoningVariant := range reasoningVariants { + t.Run(reasoningVariant.name, func(t *testing.T) { + t.Parallel() + + guarded, err := chatsanitize.ApplyAnthropicProviderToolGuard( + context.Background(), + slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), + fantasyanthropic.Name, + "claude-test", + []fantasy.Message{ + priorAssistantWithOrphanForTest(), + latestReasoningAssistantForTest(reasoningVariant.part), + }, + ) + + require.ErrorIs(t, err, chatsanitize.ErrAnthropicProviderToolPromptUnsafe) + require.Nil(t, guarded) + }) + } +} + +func TestSanitizeAnthropicProviderToolContentPreservesSignedReasoningStep(t *testing.T) { + t.Parallel() + + content := []fantasy.Content{ + fantasy.ReasoningContent{ + Text: "signed thinking", + ProviderMetadata: fantasy.ProviderMetadata{ + fantasyanthropic.Name: &fantasyanthropic.ReasoningOptionMetadata{ + Signature: "sig-step", + }, + }, + }, + fantasy.ToolCallContent{ + ToolCallID: "srvtoolu_orphan", + ToolName: "web_search", + Input: `{"query":"coder"}`, + ProviderExecuted: true, + }, + } + + sanitized, stats := chatsanitize.SanitizeAnthropicProviderToolContent(fantasyanthropic.Name, content) + + require.Equal(t, chatsanitize.AnthropicProviderToolSanitizationStats{}, stats) + require.Equal(t, content, sanitized) +} + func TestSanitizeAnthropicProviderToolHistory(t *testing.T) { t.Parallel() diff --git a/go.mod b/go.mod index f338d323a1..ab308eb51f 100644 --- a/go.mod +++ b/go.mod @@ -88,8 +88,10 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713 // when paired with reasoning, and validate function_call output pairing. // 8) coder/fantasy#33, fail closed when Anthropic or OpenAI Responses // streams close before their terminal events. -// See: https://github.com/coder/fantasy/commits/246c4ae7aff9e -replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20260507124503-246c4ae7aff9 +// 9) coder/fantasy#35, preserve Anthropic replay fidelity for signed +// reasoning and provider-executed web_search error results. +// See: https://github.com/coder/fantasy/commits/cfca5fd82c5dd +replace charm.land/fantasy => github.com/coder/fantasy v0.0.0-20260514123132-cfca5fd82c5d // coder/coder uses a fork of charmbracelet's fork of the Anthropic Go SDK // with performance improvements and Bedrock header cleanup. diff --git a/go.sum b/go.sum index 728e74c13b..8031a27eea 100644 --- a/go.sum +++ b/go.sum @@ -322,8 +322,8 @@ github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwu github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4= github.com/coder/clistat v1.2.1 h1:P9/10njXMyj5cWzIU5wkRsSy5LVQH49+tcGMsAgWX0w= github.com/coder/clistat v1.2.1/go.mod h1:m7SC0uj88eEERgvF8Kn6+w6XF21BeSr+15f7GoLAw0A= -github.com/coder/fantasy v0.0.0-20260507124503-246c4ae7aff9 h1:Tj9Gq45h0zdDz3o1Un7ESGXkxO39dg+lRpWN7lks28A= -github.com/coder/fantasy v0.0.0-20260507124503-246c4ae7aff9/go.mod h1:wZ0e3lEPqrM0XiIdAUQLvMKCLYhc3gi96MRX2wjbX44= +github.com/coder/fantasy v0.0.0-20260514123132-cfca5fd82c5d h1:CS3b2CZUDdHMwwtDoAtZF2/dzZd57yJtSJi3t86pmxE= +github.com/coder/fantasy v0.0.0-20260514123132-cfca5fd82c5d/go.mod h1:wZ0e3lEPqrM0XiIdAUQLvMKCLYhc3gi96MRX2wjbX44= github.com/coder/flog v1.1.0 h1:kbAes1ai8fIS5OeV+QAnKBQE22ty1jRF/mcAwHpLBa4= github.com/coder/flog v1.1.0/go.mod h1:UQlQvrkJBvnRGo69Le8E24Tcl5SJleAAR7gYEHzAmdQ= github.com/coder/go-httpstat v0.0.0-20230801153223-321c88088322 h1:m0lPZjlQ7vdVpRBPKfYIFlmgevoTkBxB10wv6l2gOaU=