From 072e9a212f72494585219b86c0ce68f32010bbc4 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 12 Mar 2026 13:22:09 -0700 Subject: [PATCH] fix(chatloop): keep provider-executed tool results in assistant message (#23012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When a step contains both provider-executed tool calls (e.g. Anthropic web search) and local tool calls in parallel, the next loop iteration fails with the Anthropic API claiming the regular tool call has no result. However, sending a new user message (which reloads messages from the DB) works fine. ## Root cause `toResponseMessages` was placing **all** tool results into the tool-role message, regardless of `ProviderExecuted`. When Fantasy's Anthropic provider later converted these messages for the API, it moved the provider tool result from the tool message to the **end** of the previous assistant message (`prevMsg.Content = append(...)`). This placed `web_search_tool_result` **after** the regular `tool_use` block: ``` assistant: [server_tool_use(A), tool_use(B), web_search_tool_result(A)] ← wrong order user: [tool_result(B)] ``` The persistence layer in `chatd.go` already handles this correctly — provider-executed tool results stay in the assistant message, producing the expected ordering: ``` assistant: [server_tool_use(A), web_search_tool_result(A), tool_use(B)] ← correct order user: [tool_result(B)] ``` This is why reloading from the DB fixed it. ## Fix In the `ContentTypeToolResult` case of `toResponseMessages`, route provider-executed results to `assistantParts` instead of `toolParts`, matching the persistence layer's behavior. ## Testing Added `TestToResponseMessages_ProviderExecutedToolResultInAssistantMessage` which verifies that mixed provider+local tool results are split correctly between the assistant and tool messages. --- coderd/chatd/chatloop/chatloop.go | 15 ++++- coderd/chatd/chatloop/chatloop_test.go | 76 ++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/coderd/chatd/chatloop/chatloop.go b/coderd/chatd/chatloop/chatloop.go index f7a996bc41..f3e3433e80 100644 --- a/coderd/chatd/chatloop/chatloop.go +++ b/coderd/chatd/chatloop/chatloop.go @@ -158,12 +158,23 @@ func (r stepResult) toResponseMessages() []fantasy.Message { if !ok { continue } - toolParts = append(toolParts, fantasy.ToolResultPart{ + part := fantasy.ToolResultPart{ ToolCallID: result.ToolCallID, Output: result.Result, ProviderExecuted: result.ProviderExecuted, ProviderOptions: fantasy.ProviderOptions(result.ProviderMetadata), - }) + } + // Provider-executed tool results (e.g. web_search) + // must stay in the assistant message so the result + // block appears inline after the corresponding + // server_tool_use block. This matches the persistence + // layer in chatd.go which keeps them in + // assistantBlocks. + if result.ProviderExecuted { + assistantParts = append(assistantParts, part) + } else { + toolParts = append(toolParts, part) + } default: continue } diff --git a/coderd/chatd/chatloop/chatloop_test.go b/coderd/chatd/chatloop/chatloop_test.go index 1b3633860b..dd337354da 100644 --- a/coderd/chatd/chatloop/chatloop_test.go +++ b/coderd/chatd/chatloop/chatloop_test.go @@ -499,6 +499,82 @@ func TestRun_ShutdownDuringToolExecutionReturnsContextCanceled(t *testing.T) { assert.ErrorIs(t, err, context.Canceled, "shutdown should propagate as context.Canceled") } +func TestToResponseMessages_ProviderExecutedToolResultInAssistantMessage(t *testing.T) { + t.Parallel() + + sr := stepResult{ + content: []fantasy.Content{ + // Provider-executed tool call (e.g. web_search). + fantasy.ToolCallContent{ + ToolCallID: "provider-tc-1", + ToolName: "web_search", + Input: `{"query":"coder"}`, + ProviderExecuted: true, + }, + // Provider-executed tool result — must stay in + // assistant message. + fantasy.ToolResultContent{ + ToolCallID: "provider-tc-1", + ToolName: "web_search", + ProviderExecuted: true, + ProviderMetadata: fantasy.ProviderMetadata{"anthropic": nil}, + }, + // Local tool call (e.g. read_file). + fantasy.ToolCallContent{ + ToolCallID: "local-tc-1", + ToolName: "read_file", + Input: `{"path":"main.go"}`, + ProviderExecuted: false, + }, + // Local tool result — should go into tool message. + fantasy.ToolResultContent{ + ToolCallID: "local-tc-1", + ToolName: "read_file", + Result: fantasy.ToolResultOutputContentText{Text: "some result"}, + ProviderExecuted: false, + }, + }, + } + + msgs := sr.toResponseMessages() + require.Len(t, msgs, 2, "expected assistant + tool messages") + + // First message: assistant role. + assistantMsg := msgs[0] + assert.Equal(t, fantasy.MessageRoleAssistant, assistantMsg.Role) + require.Len(t, assistantMsg.Content, 3, + "assistant message should have provider ToolCallPart, provider ToolResultPart, and local ToolCallPart") + + // Part 0: provider tool call. + providerTC, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](assistantMsg.Content[0]) + require.True(t, ok, "part 0 should be ToolCallPart") + assert.Equal(t, "provider-tc-1", providerTC.ToolCallID) + assert.True(t, providerTC.ProviderExecuted) + + // Part 1: provider tool result (inline in assistant turn). + providerTR, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](assistantMsg.Content[1]) + require.True(t, ok, "part 1 should be ToolResultPart") + assert.Equal(t, "provider-tc-1", providerTR.ToolCallID) + assert.True(t, providerTR.ProviderExecuted) + + // Part 2: local tool call. + localTC, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](assistantMsg.Content[2]) + require.True(t, ok, "part 2 should be ToolCallPart") + assert.Equal(t, "local-tc-1", localTC.ToolCallID) + assert.False(t, localTC.ProviderExecuted) + + // Second message: tool role. + toolMsg := msgs[1] + assert.Equal(t, fantasy.MessageRoleTool, toolMsg.Role) + require.Len(t, toolMsg.Content, 1, + "tool message should have only the local ToolResultPart") + + localTR, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](toolMsg.Content[0]) + require.True(t, ok, "tool part should be ToolResultPart") + assert.Equal(t, "local-tc-1", localTR.ToolCallID) + assert.False(t, localTR.ProviderExecuted) +} + func hasAnthropicEphemeralCacheControl(message fantasy.Message) bool { if len(message.ProviderOptions) == 0 { return false diff --git a/go.mod b/go.mod index 84b1784c3b..a22334d40b 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ replace github.com/spf13/afero => github.com/aslilac/afero v0.0.0-20250403163713 // 1) Adds thinking effort to Anthropic provider // 2) Downgraded to Go 1.25 due to issue with Windows CI // https://github.com/kylecarbs/fantasy/compare/main...kylecarbs:fantasy:cj/go1.25 -replace charm.land/fantasy => github.com/kylecarbs/fantasy v0.0.0-20260312154649-e4bbc7bb3054 +replace charm.land/fantasy => github.com/kylecarbs/fantasy v0.0.0-20260312195846-2681eb9ddd20 replace github.com/charmbracelet/anthropic-sdk-go => github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab diff --git a/go.sum b/go.sum index 8a82ba436b..f18b4984f0 100644 --- a/go.sum +++ b/go.sum @@ -797,8 +797,8 @@ github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:5UMY github.com/kylecarbs/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab/go.mod h1:hqlYqR7uPKOKfnNeicUbZp0Ps0GeYFlKYtwh5HGDCx8= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3 h1:Z9/bo5PSeMutpdiKYNt/TTSfGM1Ll0naj3QzYX9VxTc= github.com/kylecarbs/chroma/v2 v2.0.0-20240401211003-9e036e0631f3/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk= -github.com/kylecarbs/fantasy v0.0.0-20260312154649-e4bbc7bb3054 h1:PlN4sfr5xfHsw5HnHEHEu6TqMZr1icbwt1I1QeqitmI= -github.com/kylecarbs/fantasy v0.0.0-20260312154649-e4bbc7bb3054/go.mod h1:p6cYJVG8D8AC51MgejAKCMu0myRyQ+vKLuoJQ3biaXo= +github.com/kylecarbs/fantasy v0.0.0-20260312195846-2681eb9ddd20 h1:AEaj4CwdJelIN8GgDZH5xVBP4WFvGqkETGmRO8YBKSA= +github.com/kylecarbs/fantasy v0.0.0-20260312195846-2681eb9ddd20/go.mod h1:p6cYJVG8D8AC51MgejAKCMu0myRyQ+vKLuoJQ3biaXo= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e h1:OP0ZMFeZkUnOzTFRfpuK3m7Kp4fNvC6qN+exwj7aI4M= github.com/kylecarbs/spinner v1.18.2-0.20220329160715-20702b5af89e/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=