mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Closes CODAGT-1141
Opus 5.5 and Fable 5.1 bind each signed thinking block to the exact
prefix it was created under. chatd rebuilds the system prompt and tools
from live state before every model call, so if that state changes
mid-turn (e.g. after `start_workspace`), the replayed block no longer
matches and Anthropic returns a 400, which we treated as non-retryable:
```
messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation...
```
To fix, we'll do what Anthropic recommends: on that 400, retry once with
`thinking.block_binding.prefix_mismatch_behavior: "drop_block"` and the
`thinking-binding-controls-2026-08-01` beta, then send both upfront for
that provider and model once the retry succeeds. That's only a hint,
since BYOK users on the same provider can have different keys, so if a
request with `drop_block` gets a 400 we retry once without it and clear
the hint. The retry wraps the model itself, so compaction and the
advisor get it too. The hint is in-memory, so a restart costs one extra
400 per model on each replica. Chats that never hit the error are
unchanged.
aibridge's InvokeModel path was stripping the beta and deleting
`block_binding`, so it now forwards both or neither, for any model. It
also renames the field to `mismatch_behavior`, since that's what Bedrock
calls it, and only some regions accept the Anthropic name.
Always sending `drop_block` would change bytes on working chats, and AWS
doesn't document Mantle support for the beta. Dropping stale thinking
client-side means guessing what the server will reject (the signature is
opaque, and enforcement depends on the model and account), so it would
still need the retry. The proper fix is an append-only prefix, but
that's a much bigger change, and it would also still need this retry.
Mantle does accept the beta, at least for Opus 5.5 on us-east-1 (see
testing below).
<details>
<summary>Testing</summary>
I tested this manually on `9c2b4d6` with `develop.sh`, using Opus 5.5 on
Bedrock Mantle us-east-1 via the dogfood AI Gateway, with a small
logging proxy in front to record the beta header and `block_binding` on
each request. The direct Anthropic API doesn't enforce the binding for
our account, so I couldn't reproduce it there.
To trigger it, I just asked the agent to reason about something, create
a workspace from the `docker` template and run a command in it, all in
one turn. On the merge base, the turn fails with the 400 above as soon
as the workspace is created. On this branch there's one 400, the
`drop_block` retry succeeds, the turn finishes with no error in the UI,
and every request after that sends `drop_block` upfront:
```
status=400 beta="" block_binding=""
status=200 beta="thinking-binding-controls-2026-08-01" block_binding={"prefix_mismatch_behavior":"drop_block"}
status=200 beta="thinking-binding-controls-2026-08-01" block_binding={"prefix_mismatch_behavior":"drop_block"}
```
The chat that failed on the merge base also recovers once it's continued
on this branch, and a restart costs one extra 400 as expected. I also
had the proxy reject any request with `drop_block`, to mimic a BYOK key
without the beta, and the retry without it succeeded and cleared the
hint, so the next chat didn't send it.
I didn't test the InvokeModel path, since I don't have AWS credentials
for it.
</details>
78 lines
2.8 KiB
Go
78 lines
2.8 KiB
Go
package chatd_test
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/x/chatd"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestThinkingBinding_RetriesWithDropBlock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
type dropBlockSent struct {
|
|
beta bool
|
|
field bool
|
|
}
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
db, ps := dbtestutil.NewDB(t)
|
|
var (
|
|
mu sync.Mutex
|
|
sent []dropBlockSent
|
|
)
|
|
anthropicURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
|
if !req.Stream {
|
|
return chattest.AnthropicNonStreamingResponse("title")
|
|
}
|
|
mu.Lock()
|
|
sent = append(sent, dropBlockSent{
|
|
beta: strings.Contains(req.Header.Get("Anthropic-Beta"), "thinking-binding-controls-2026-08-01"),
|
|
field: strings.Contains(string(req.Thinking), `"prefix_mismatch_behavior":"drop_block"`),
|
|
})
|
|
n, dropBlock := len(sent), sent[len(sent)-1].field
|
|
mu.Unlock()
|
|
if n == 4 || n == 6 {
|
|
return chattest.AnthropicStreamingResponse(chattest.AnthropicTextChunks("ok")...)
|
|
}
|
|
if dropBlock {
|
|
return chattest.AnthropicResponse{Error: &chattest.ErrorResponse{
|
|
StatusCode: http.StatusBadRequest,
|
|
Type: "invalid_request_error",
|
|
Message: "thinking.block_binding: Extra inputs are not permitted",
|
|
}}
|
|
}
|
|
return chattest.AnthropicResponse{Error: &chattest.ErrorResponse{
|
|
StatusCode: http.StatusBadRequest,
|
|
Type: "invalid_request_error",
|
|
Message: "messages.1.content.0: Invalid `signature` in `thinking` block. The block is bound to a different conversation.",
|
|
}}
|
|
})
|
|
user, org, model := seedAnthropicChatDependencies(t, db, anthropicURL)
|
|
server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) {
|
|
cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, anthropicURL, chattest.WithPreservePath()))
|
|
})
|
|
|
|
// Chat 1's drop_block retry fails, so chat 2 still starts without
|
|
// drop_block. Chat 2's retry succeeds, so chat 3 sends drop_block
|
|
// first. That is rejected, and the retry without it succeeds and
|
|
// clears the hint, so chat 4 starts without drop_block again.
|
|
for _, want := range []database.ChatStatus{database.ChatStatusError, database.ChatStatusWaiting, database.ChatStatusWaiting, database.ChatStatusError} {
|
|
chat := createChatThroughServer(ctx, t, db, server, org.ID, user.ID, model.ID, "hello")
|
|
waitForChatStatus(ctx, t, db, chat.ID, want)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
withDropBlock := dropBlockSent{beta: true, field: true}
|
|
require.Equal(t, []dropBlockSent{{}, withDropBlock, {}, withDropBlock, withDropBlock, {}, {}, withDropBlock}, sent)
|
|
}
|