diff --git a/internal/adapter/claude/handler_stream_test.go b/internal/adapter/claude/handler_stream_test.go index f3a8b6e..dda425a 100644 --- a/internal/adapter/claude/handler_stream_test.go +++ b/internal/adapter/claude/handler_stream_test.go @@ -357,3 +357,36 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing. }) } } + +func TestHandleClaudeStreamRealtimeDoesNotStopOnUnclosedFencedToolExample(t *testing.T) { + h := &Handler{} + resp := makeClaudeSSEHTTPResponse( + "data: {\"p\":\"response/content\",\"v\":\"Here is an example:\\n```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"Bash\\\",\\\"input\\\":{\\\"command\\\":\\\"pwd\\\"}}]}\"}", + "data: {\"p\":\"response/content\",\"v\":\"\\n```\\nDo not execute it.\"}", + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"}) + + frames := parseClaudeFrames(t, rec.Body.String()) + for _, f := range findClaudeFrames(frames, "content_block_start") { + contentBlock, _ := f.Payload["content_block"].(map[string]any) + if contentBlock["type"] == "tool_use" { + t.Fatalf("unexpected tool_use for fenced example, body=%s", rec.Body.String()) + } + } + + foundEndTurn := false + for _, f := range findClaudeFrames(frames, "message_delta") { + delta, _ := f.Payload["delta"].(map[string]any) + if delta["stop_reason"] == "end_turn" { + foundEndTurn = true + break + } + } + if !foundEndTurn { + t.Fatalf("expected stop_reason=end_turn, body=%s", rec.Body.String()) + } +} diff --git a/internal/adapter/claude/stream_runtime_core.go b/internal/adapter/claude/stream_runtime_core.go index 6bd8e94..fead90a 100644 --- a/internal/adapter/claude/stream_runtime_core.go +++ b/internal/adapter/claude/stream_runtime_core.go @@ -117,6 +117,9 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse s.text.WriteString(p.Text) if s.bufferToolContent { + if hasUnclosedCodeFence(s.text.String()) { + continue + } detected := util.ParseToolCalls(s.text.String(), s.toolNames) if len(detected) > 0 { s.finalize("tool_use") @@ -154,3 +157,7 @@ func (s *claudeStreamRuntime) onParsed(parsed sse.LineResult) streamengine.Parse return streamengine.ParsedDecision{ContentSeen: contentSeen} } + +func hasUnclosedCodeFence(text string) bool { + return strings.Count(text, "```")%2 == 1 +} diff --git a/internal/util/toolcalls_parse_markup.go b/internal/util/toolcalls_parse_markup.go index 262fd59..b7b2908 100644 --- a/internal/util/toolcalls_parse_markup.go +++ b/internal/util/toolcalls_parse_markup.go @@ -32,8 +32,8 @@ func parseXMLToolCalls(text string) []ParsedToolCall { if call, ok := parseFunctionCallTagStyle(text); ok { return []ParsedToolCall{call} } - if call, ok := parseAntmlFunctionCallStyle(text); ok { - return []ParsedToolCall{call} + if calls := parseAntmlFunctionCallStyles(text); len(calls) > 0 { + return calls } if call, ok := parseInvokeFunctionCallStyle(text); ok { return []ParsedToolCall{call} @@ -140,8 +140,24 @@ func parseFunctionCallTagStyle(text string) (ParsedToolCall, bool) { return ParsedToolCall{Name: name, Input: input}, true } -func parseAntmlFunctionCallStyle(text string) (ParsedToolCall, bool) { - m := antmlFunctionCallPattern.FindStringSubmatch(text) +func parseAntmlFunctionCallStyles(text string) []ParsedToolCall { + matches := antmlFunctionCallPattern.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return nil + } + out := make([]ParsedToolCall, 0, len(matches)) + for _, m := range matches { + if call, ok := parseSingleAntmlFunctionCallMatch(m); ok { + out = append(out, call) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func parseSingleAntmlFunctionCallMatch(m []string) (ParsedToolCall, bool) { if len(m) < 3 { return ParsedToolCall{}, false } diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index 6fd3d59..81e5d32 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -260,3 +260,14 @@ func TestParseToolCallsSupportsAntmlFunctionAttributeWithParametersTag(t *testin t.Fatalf("expected command argument, got %#v", calls[0].Input) } } + +func TestParseToolCallsSupportsMultipleAntmlFunctionCalls(t *testing.T) { + text := `{"command":"pwd"}{"file_path":"README.md"}` + calls := ParseToolCalls(text, []string{"bash", "read"}) + if len(calls) != 2 { + t.Fatalf("expected 2 calls, got %#v", calls) + } + if calls[0].Name != "bash" || calls[1].Name != "read" { + t.Fatalf("expected canonical names [bash read], got %#v", calls) + } +}