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=