fix: preserve Anthropic replay fidelity (#25377)

Anthropic is strict about replaying the latest assistant turn once it
contains signed or redacted reasoning. We were still mutating that turn
in a few Coder-owned places: dropping empty reasoning blocks on replay,
rewriting provider-tool history during sanitization, and in the worst
case sending a prompt we already knew Anthropic would reject.

This patch keeps the latest signed assistant immutable through Coder's
replay and sanitization paths, preserves empty signed or redacted
reasoning anywhere Coder owns the ledger, and fails before the provider
call if the prompt is still unsafe.

It also bumps the existing `coder/fantasy` `coder_2_33` fork that `main`
already uses to the commit containing coder/fantasy#35. These fixes have
also been upstreamed to charmbracelet/fantasy.

Closes CODAGT-409.
This commit is contained in:
Ethan
2026-05-18 15:20:33 +10:00
committed by GitHub
parent 3723f7a0c7
commit e75bd3aca4
9 changed files with 822 additions and 48 deletions
+62 -17
View File
@@ -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,
@@ -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."
+62 -4
View File
@@ -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
+4 -3
View File
@@ -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{
@@ -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{
+155 -20
View File
@@ -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)
@@ -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()
+4 -2
View File
@@ -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.
+2 -2
View File
@@ -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=