diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go index 0d85afd..00a0e8d 100644 --- a/internal/adapter/openai/handler_toolcall_test.go +++ b/internal/adapter/openai/handler_toolcall_test.go @@ -211,7 +211,7 @@ func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) { } } -func TestHandleNonStreamEmbeddedToolCallExampleRemainsText(t *testing.T) { +func TestHandleNonStreamEmbeddedToolCallExamplePromotesToolCall(t *testing.T) { h := &Handler{} resp := makeSSEHTTPResponse( `data: {"p":"response/content","v":"下面是示例:"}`, @@ -229,16 +229,17 @@ func TestHandleNonStreamEmbeddedToolCallExampleRemainsText(t *testing.T) { out := decodeJSONBody(t, rec.Body.String()) choices, _ := out["choices"].([]any) choice, _ := choices[0].(map[string]any) - if choice["finish_reason"] != "stop" { - t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"]) + if choice["finish_reason"] != "tool_calls" { + t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"]) } msg, _ := choice["message"].(map[string]any) - if _, ok := msg["tool_calls"]; ok { - t.Fatalf("did not expect tool_calls field for embedded example: %#v", msg["tool_calls"]) + toolCalls, _ := msg["tool_calls"].([]any) + if len(toolCalls) != 1 { + t.Fatalf("expected one tool_call field for embedded example: %#v", msg["tool_calls"]) } content, _ := msg["content"].(string) - if !strings.Contains(content, "下面是示例:") || !strings.Contains(content, "请勿执行。") || !strings.Contains(content, `"tool_calls"`) { - t.Fatalf("expected embedded example to remain plain text, got %#v", content) + if strings.Contains(content, `"tool_calls"`) { + t.Fatalf("expected raw tool_calls json stripped from content, got %#v", content) } } diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go index f62ff13..7d15ede 100644 --- a/internal/adapter/openai/responses_stream_test.go +++ b/internal/adapter/openai/responses_stream_test.go @@ -297,7 +297,7 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) { } } -func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *testing.T) { +func TestHandleResponsesStreamThinkingAndMixedToolExampleEmitsFunctionCall(t *testing.T) { h := &Handler{} req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) rec := httptest.NewRecorder() @@ -333,6 +333,7 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te responseObj, _ := completedPayload["response"].(map[string]any) output, _ := responseObj["output"].([]any) hasMessage := false + hasFunctionCall := false for _, item := range output { m, _ := item.(map[string]any) if m == nil { @@ -342,12 +343,15 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te hasMessage = true } if asString(m["type"]) == "function_call" { - t.Fatalf("did not expect function_call output for mixed prose tool example, output=%#v", output) + hasFunctionCall = true } } if !hasMessage { t.Fatalf("expected message output for mixed prose tool example, output=%#v", output) } + if !hasFunctionCall { + t.Fatalf("expected function_call output for mixed prose tool example, output=%#v", output) + } } func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) { diff --git a/internal/adapter/openai/stream_status_test.go b/internal/adapter/openai/stream_status_test.go index 033dc37..c76d881 100644 --- a/internal/adapter/openai/stream_status_test.go +++ b/internal/adapter/openai/stream_status_test.go @@ -171,15 +171,15 @@ func TestResponsesNonStreamMixedProseToolPayloadHandlerPath(t *testing.T) { t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String()) } outputText, _ := out["output_text"].(string) - if outputText == "" { - t.Fatalf("expected output_text preserved for mixed prose payload") + if outputText != "" { + t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText) } output, _ := out["output"].([]any) if len(output) != 1 { t.Fatalf("expected one output item, got %#v", output) } first, _ := output[0].(map[string]any) - if first["type"] != "message" { - t.Fatalf("expected message output item, got %#v", output) + if first["type"] != "function_call" { + t.Fatalf("expected function_call output item, got %#v", output) } } diff --git a/internal/format/openai/render_test.go b/internal/format/openai/render_test.go index 7a9d897..2ec05c6 100644 --- a/internal/format/openai/render_test.go +++ b/internal/format/openai/render_test.go @@ -45,7 +45,7 @@ func TestBuildResponseObjectToolCallsFollowChatShape(t *testing.T) { } } -func TestBuildResponseObjectTreatsMixedProseToolPayloadAsText(t *testing.T) { +func TestBuildResponseObjectPromotesMixedProseToolPayloadToFunctionCall(t *testing.T) { obj := BuildResponseObject( "resp_test", "gpt-4o", @@ -56,16 +56,16 @@ func TestBuildResponseObjectTreatsMixedProseToolPayloadAsText(t *testing.T) { ) outputText, _ := obj["output_text"].(string) - if outputText == "" { - t.Fatalf("expected output_text preserved for mixed prose payload") + if outputText != "" { + t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText) } output, _ := obj["output"].([]any) if len(output) != 1 { - t.Fatalf("expected one message output item, got %#v", obj["output"]) + t.Fatalf("expected one function_call output item, got %#v", obj["output"]) } first, _ := output[0].(map[string]any) - if first["type"] != "message" { - t.Fatalf("expected message output type, got %#v", first["type"]) + if first["type"] != "function_call" { + t.Fatalf("expected function_call output type, got %#v", first["type"]) } } diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 726009c..e55861b 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -75,21 +75,19 @@ func ParseStandaloneToolCalls(text string, availableToolNames []string) []Parsed func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) ToolCallParseResult { result := ToolCallParseResult{} - trimmed := strings.TrimSpace(text) + trimmed := strings.TrimSpace(stripFencedCodeBlocks(text)) if trimmed == "" { return result } - if looksLikeToolExampleContext(trimmed) { - return result - } result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed) - candidates := []string{trimmed} + candidates := buildToolCallCandidates(trimmed) + var parsed []ParsedToolCall for _, candidate := range candidates { candidate = strings.TrimSpace(candidate) if candidate == "" { continue } - parsed := parseToolCallsPayload(candidate) + parsed = parseToolCallsPayload(candidate) if len(parsed) == 0 { parsed = parseXMLToolCalls(candidate) } @@ -100,14 +98,23 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) parsed = parseTextKVToolCalls(candidate) } if len(parsed) > 0 { - result.SawToolCallSyntax = true - calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames) - result.Calls = calls - result.RejectedToolNames = rejectedNames - result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0 - return result + break } } + if len(parsed) == 0 { + parsed = parseXMLToolCalls(trimmed) + if len(parsed) == 0 { + parsed = parseTextKVToolCalls(trimmed) + if len(parsed) == 0 { + return result + } + } + } + result.SawToolCallSyntax = true + calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames) + result.Calls = calls + result.RejectedToolNames = rejectedNames + result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0 return result } diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index e3fae5d..da6e59a 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -99,10 +99,10 @@ func TestFormatOpenAIToolCalls(t *testing.T) { } } -func TestParseStandaloneToolCallsOnlyMatchesStandalonePayload(t *testing.T) { +func TestParseStandaloneToolCallsSupportsMixedProsePayload(t *testing.T) { mixed := `这里是示例:{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` - if calls := ParseStandaloneToolCalls(mixed, []string{"search"}); len(calls) != 0 { - t.Fatalf("expected standalone parser to ignore mixed prose, got %#v", calls) + if calls := ParseStandaloneToolCalls(mixed, []string{"search"}); len(calls) != 1 { + t.Fatalf("expected standalone parser to parse mixed prose payload, got %#v", calls) } standalone := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` diff --git a/tests/compat/expected/toolcalls_standalone_mixed_prose.json b/tests/compat/expected/toolcalls_standalone_mixed_prose.json index 0fcce27..124de59 100644 --- a/tests/compat/expected/toolcalls_standalone_mixed_prose.json +++ b/tests/compat/expected/toolcalls_standalone_mixed_prose.json @@ -1,6 +1,13 @@ { - "calls": [], + "calls": [ + { + "name": "read_file", + "input": { + "path": "README.MD" + } + } + ], "sawToolCallSyntax": true, "rejectedByPolicy": false, "rejectedToolNames": [] -} \ No newline at end of file +}