mirror of
https://github.com/ollama/ollama.git
synced 2026-03-27 02:58:43 +07:00
anthropic: close thinking block before tool_use when no text in between (#14825)
Root cause: StreamConverter.Process() only incremented contentIndex when closing a thinking block if text content was present. When a model emitted thinking followed directly by a tool_use block (no text in between), thinkingDone was never set and contentIndex was not incremented, causing the tool_use content_block_start to reuse index 0. Clients expecting sequential indices would then fail to find the tool content block. Fix: In the tool call loop, close any open thinking block (thinkingStarted && !thinkingDone) and increment contentIndex before opening the tool_use block, mirroring the existing logic that closes an open text block. Fixes #14816
This commit is contained in:
@@ -852,6 +852,19 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
continue
|
||||
}
|
||||
|
||||
// Close thinking block if still open (thinking → tool_use without text in between)
|
||||
if c.thinkingStarted && !c.thinkingDone {
|
||||
c.thinkingDone = true
|
||||
events = append(events, StreamEvent{
|
||||
Event: "content_block_stop",
|
||||
Data: ContentBlockStopEvent{
|
||||
Type: "content_block_stop",
|
||||
Index: c.contentIndex,
|
||||
},
|
||||
})
|
||||
c.contentIndex++
|
||||
}
|
||||
|
||||
if c.textStarted {
|
||||
events = append(events, StreamEvent{
|
||||
Event: "content_block_stop",
|
||||
|
||||
@@ -799,6 +799,107 @@ func TestStreamConverter_WithToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamConverter_ThinkingDirectlyFollowedByToolCall verifies that when a
|
||||
// model emits a thinking block followed directly by a tool_use block (with no
|
||||
// text block in between), the streaming converter correctly closes the thinking
|
||||
// block and increments the content index before opening the tool_use block.
|
||||
// Previously, the converter reused contentIndex=0 for the tool_use block,
|
||||
// which caused "Content block not found" errors in clients. See #14816.
|
||||
func TestStreamConverter_ThinkingDirectlyFollowedByToolCall(t *testing.T) {
|
||||
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||
|
||||
// First chunk: thinking content (no text)
|
||||
resp1 := api.ChatResponse{
|
||||
Model: "test-model",
|
||||
Message: api.Message{
|
||||
Role: "assistant",
|
||||
Thinking: "I should call the tool.",
|
||||
},
|
||||
}
|
||||
events1 := conv.Process(resp1)
|
||||
|
||||
// Should have: message_start, content_block_start(thinking), content_block_delta(thinking)
|
||||
if len(events1) < 3 {
|
||||
t.Fatalf("expected at least 3 events for thinking chunk, got %d", len(events1))
|
||||
}
|
||||
if events1[0].Event != "message_start" {
|
||||
t.Errorf("expected first event 'message_start', got %q", events1[0].Event)
|
||||
}
|
||||
thinkingStart, ok := events1[1].Data.(ContentBlockStartEvent)
|
||||
if !ok || thinkingStart.ContentBlock.Type != "thinking" {
|
||||
t.Errorf("expected content_block_start(thinking) as second event, got %+v", events1[1])
|
||||
}
|
||||
if thinkingStart.Index != 0 {
|
||||
t.Errorf("expected thinking block at index 0, got %d", thinkingStart.Index)
|
||||
}
|
||||
|
||||
// Second chunk: tool call (no text between thinking and tool)
|
||||
resp2 := api.ChatResponse{
|
||||
Model: "test-model",
|
||||
Message: api.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []api.ToolCall{
|
||||
{
|
||||
ID: "call_abc",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "ask_user",
|
||||
Arguments: testArgs(map[string]any{"question": "cats or dogs?"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Done: true,
|
||||
DoneReason: "stop",
|
||||
Metrics: api.Metrics{PromptEvalCount: 10, EvalCount: 5},
|
||||
}
|
||||
events2 := conv.Process(resp2)
|
||||
|
||||
// Expect: content_block_stop(index=0), content_block_start(tool_use, index=1),
|
||||
// content_block_delta(input_json_delta, index=1), content_block_stop(index=1),
|
||||
// message_delta, message_stop
|
||||
var thinkingStop, toolStart, toolDelta, toolStop *StreamEvent
|
||||
for i := range events2 {
|
||||
e := &events2[i]
|
||||
switch e.Event {
|
||||
case "content_block_stop":
|
||||
if stop, ok := e.Data.(ContentBlockStopEvent); ok {
|
||||
if stop.Index == 0 && thinkingStop == nil {
|
||||
thinkingStop = e
|
||||
} else if stop.Index == 1 {
|
||||
toolStop = e
|
||||
}
|
||||
}
|
||||
case "content_block_start":
|
||||
if start, ok := e.Data.(ContentBlockStartEvent); ok && start.ContentBlock.Type == "tool_use" {
|
||||
toolStart = e
|
||||
}
|
||||
case "content_block_delta":
|
||||
if delta, ok := e.Data.(ContentBlockDeltaEvent); ok && delta.Delta.Type == "input_json_delta" {
|
||||
toolDelta = e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if thinkingStop == nil {
|
||||
t.Error("expected content_block_stop for thinking block (index 0)")
|
||||
}
|
||||
if toolStart == nil {
|
||||
t.Fatal("expected content_block_start for tool_use block")
|
||||
}
|
||||
if start, ok := toolStart.Data.(ContentBlockStartEvent); !ok || start.Index != 1 {
|
||||
t.Errorf("expected tool_use block at index 1, got %+v", toolStart.Data)
|
||||
}
|
||||
if toolDelta == nil {
|
||||
t.Fatal("expected input_json_delta event for tool call")
|
||||
}
|
||||
if delta, ok := toolDelta.Data.(ContentBlockDeltaEvent); !ok || delta.Index != 1 {
|
||||
t.Errorf("expected tool delta at index 1, got %+v", toolDelta.Data)
|
||||
}
|
||||
if toolStop == nil {
|
||||
t.Error("expected content_block_stop for tool_use block (index 1)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamConverter_ToolCallWithUnmarshalableArgs(t *testing.T) {
|
||||
// Test that unmarshalable arguments (like channels) are handled gracefully
|
||||
// and don't cause a panic or corrupt stream
|
||||
|
||||
Reference in New Issue
Block a user