mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user