From 892213071ad202748e12ead2b47b764d02f14216 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sun, 8 Mar 2026 00:12:43 +0800 Subject: [PATCH] Align Go/JS tool-call parsing semantics and compat fixtures --- docs/toolcall-semantics.md | 40 ++++++ internal/compat/go_compat_test.go | 25 ++-- .../js/helpers/stream-tool-sieve/parse.js | 61 +++++++- .../stream-tool-sieve/parse_payload.js | 105 ++++++++++++++ internal/util/toolcalls_markup.go | 134 ++++++++++++++++++ internal/util/toolcalls_parse.go | 44 ++++-- .../expected/toolcalls_allowlist_empty.json | 9 +- .../toolcalls_case_insensitive_canonical.json | 7 +- .../expected/toolcalls_fenced_json.json | 7 +- .../expected/toolcalls_function_call_tag.json | 13 ++ .../expected/toolcalls_invoke_attr.json | 13 ++ .../expected/toolcalls_loose_normalize.json | 13 ++ .../toolcalls_namespace_tail_normalize.json | 13 ++ .../toolcalls_standalone_fenced_example.json | 7 +- .../toolcalls_standalone_mixed_prose.json | 7 +- .../expected/toolcalls_standalone_pure.json | 7 +- .../expected/toolcalls_unknown_name.json | 9 +- .../expected/toolcalls_xml_tool_call.json | 13 ++ .../fixtures/toolcalls/function_call_tag.json | 6 + .../fixtures/toolcalls/invoke_attr.json | 6 + .../fixtures/toolcalls/loose_normalize.json | 6 + .../toolcalls/namespace_tail_normalize.json | 6 + .../fixtures/toolcalls/xml_tool_call.json | 6 + tests/node/js_compat_test.js | 9 +- 24 files changed, 519 insertions(+), 47 deletions(-) create mode 100644 docs/toolcall-semantics.md create mode 100644 internal/util/toolcalls_markup.go create mode 100644 tests/compat/expected/toolcalls_function_call_tag.json create mode 100644 tests/compat/expected/toolcalls_invoke_attr.json create mode 100644 tests/compat/expected/toolcalls_loose_normalize.json create mode 100644 tests/compat/expected/toolcalls_namespace_tail_normalize.json create mode 100644 tests/compat/expected/toolcalls_xml_tool_call.json create mode 100644 tests/compat/fixtures/toolcalls/function_call_tag.json create mode 100644 tests/compat/fixtures/toolcalls/invoke_attr.json create mode 100644 tests/compat/fixtures/toolcalls/loose_normalize.json create mode 100644 tests/compat/fixtures/toolcalls/namespace_tail_normalize.json create mode 100644 tests/compat/fixtures/toolcalls/xml_tool_call.json diff --git a/docs/toolcall-semantics.md b/docs/toolcall-semantics.md new file mode 100644 index 0000000..50165a9 --- /dev/null +++ b/docs/toolcall-semantics.md @@ -0,0 +1,40 @@ +# Tool call parsing semantics (Go canonical spec) + +This document defines the cross-runtime contract for `ParseToolCallsDetailed` / `parseToolCallsDetailed`. + +## Output contract + +- `calls`: accepted tool calls with normalized tool names. +- `sawToolCallSyntax`: true when tool-call-like syntax is detected (`tool_calls`, ``, ``, ``) or a valid call is parsed. +- `rejectedByPolicy`: true when parser extracted call syntax but all calls are rejected by allow-list policy. +- `rejectedToolNames`: de-duplicated rejected tool names in first-seen order. + +## Parse pipeline + +1. Strip fenced code blocks for non-standalone parsing. +2. Build candidates from: + - full text, + - fenced JSON snippets, + - extracted JSON objects around `tool_calls`, + - first `{` to last `}` object slice. +3. Parse each candidate in order: + - JSON payload parser (`tool_calls`, list, single call object), + - markup parser (``, ``, ``; supports attributes + nested fields). +4. Stop at first candidate that yields at least one call. + +## Name normalization policy + +When matching parsed names against configured tools: + +1. exact match, +2. case-insensitive match, +3. namespace tail match (`a.b.c` => `c`), +4. loose alnum match (remove non `[a-z0-9]`, compare). + +## Standalone mode + +Standalone mode (`ParseStandaloneToolCallsDetailed`) parses the whole input directly (no candidate slicing), while still applying: + +- example-context guard, +- JSON then markup fallback, +- the same allow-list normalization policy. diff --git a/internal/compat/go_compat_test.go b/internal/compat/go_compat_test.go index fa68eb2..7768e4b 100644 --- a/internal/compat/go_compat_test.go +++ b/internal/compat/go_compat_test.go @@ -73,22 +73,31 @@ func TestGoCompatToolcallFixtures(t *testing.T) { mustLoadJSON(t, fixturePath, &fixture) var expected struct { - Calls []util.ParsedToolCall `json:"calls"` + Calls []util.ParsedToolCall `json:"calls"` + SawToolCallSyntax bool `json:"sawToolCallSyntax"` + RejectedByPolicy bool `json:"rejectedByPolicy"` + RejectedToolNames []string `json:"rejectedToolNames"` } mustLoadJSON(t, expectedPath, &expected) - var got []util.ParsedToolCall + var got util.ToolCallParseResult switch strings.ToLower(strings.TrimSpace(fixture.Mode)) { case "standalone": - got = util.ParseStandaloneToolCalls(fixture.Text, fixture.ToolNames) + got = util.ParseStandaloneToolCallsDetailed(fixture.Text, fixture.ToolNames) default: - got = util.ParseToolCalls(fixture.Text, fixture.ToolNames) + got = util.ParseToolCallsDetailed(fixture.Text, fixture.ToolNames) } - if len(got) == 0 && len(expected.Calls) == 0 { - continue + if got.Calls == nil { + got.Calls = []util.ParsedToolCall{} } - if !reflect.DeepEqual(got, expected.Calls) { - t.Fatalf("toolcall fixture %s mismatch:\n got=%#v\nwant=%#v", name, got, expected.Calls) + if got.RejectedToolNames == nil { + got.RejectedToolNames = []string{} + } + if !reflect.DeepEqual(got.Calls, expected.Calls) || + got.SawToolCallSyntax != expected.SawToolCallSyntax || + got.RejectedByPolicy != expected.RejectedByPolicy || + !reflect.DeepEqual(got.RejectedToolNames, expected.RejectedToolNames) { + t.Fatalf("toolcall fixture %s mismatch:\n got=%#v\nwant=%#v", name, got, expected) } } } diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index bf21046..1b882ff 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -8,8 +8,11 @@ const { stripFencedCodeBlocks, buildToolCallCandidates, parseToolCallsPayload, + parseMarkupToolCalls, } = require('./parse_payload'); +const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g; + function extractToolNames(tools) { if (!Array.isArray(tools) || tools.length === 0) { return []; @@ -41,12 +44,15 @@ function parseToolCallsDetailed(text, toolNames) { if (!toStringSafe(sanitized)) { return result; } - result.sawToolCallSyntax = sanitized.toLowerCase().includes('tool_calls'); + result.sawToolCallSyntax = looksLikeToolCallSyntax(sanitized); const candidates = buildToolCallCandidates(sanitized); let parsed = []; for (const c of candidates) { parsed = parseToolCallsPayload(c); + if (parsed.length === 0) { + parsed = parseMarkupToolCalls(c); + } if (parsed.length > 0) { result.sawToolCallSyntax = true; break; @@ -73,15 +79,17 @@ function parseStandaloneToolCallsDetailed(text, toolNames) { if (!trimmed) { return result; } + if (trimmed.includes('```')) { + return result; + } if (looksLikeToolExampleContext(trimmed)) { return result; } - result.sawToolCallSyntax = trimmed.toLowerCase().includes('tool_calls'); - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { - return result; + result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed); + let parsed = parseToolCallsPayload(trimmed); + if (parsed.length === 0) { + parsed = parseMarkupToolCalls(trimmed); } - - const parsed = parseToolCallsPayload(trimmed); if (parsed.length === 0) { return result; } @@ -146,7 +154,7 @@ function filterToolCallsDetailed(parsed, toolNames) { if (allowed.has(tc.name)) { matchedName = tc.name; } else { - matchedName = allowedCanonical.get(tc.name.toLowerCase()) || ''; + matchedName = resolveAllowedToolName(tc.name, allowed, allowedCanonical); } if (!matchedName) { if (!seenRejected.has(tc.name)) { @@ -163,6 +171,45 @@ function filterToolCallsDetailed(parsed, toolNames) { return { calls, rejectedToolNames: rejected }; } +function resolveAllowedToolName(name, allowed, allowedCanonical) { + const normalizedName = toStringSafe(name).trim(); + if (!normalizedName) { + return ''; + } + if (allowed.has(normalizedName)) { + return normalizedName; + } + const lower = normalizedName.toLowerCase(); + if (allowedCanonical.has(lower)) { + return allowedCanonical.get(lower); + } + const idx = lower.lastIndexOf('.'); + if (idx >= 0 && idx < lower.length - 1) { + const tail = lower.slice(idx + 1); + if (allowedCanonical.has(tail)) { + return allowedCanonical.get(tail); + } + } + const loose = lower.replace(TOOL_NAME_LOOSE_PATTERN, ''); + if (!loose) { + return ''; + } + for (const [candidateLower, canonical] of allowedCanonical.entries()) { + if (candidateLower.replace(TOOL_NAME_LOOSE_PATTERN, '') === loose) { + return canonical; + } + } + return ''; +} + +function looksLikeToolCallSyntax(text) { + const lower = toStringSafe(text).toLowerCase(); + return lower.includes('tool_calls') + || lower.includes(']*)>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi; +const TOOL_CALL_MARKUP_SELFCLOSE_PATTERN = /<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)\/>/gi; +const TOOL_CALL_MARKUP_NAME_TAG_PATTERN = /<(?:[a-z0-9_:-]+:)?(name|function)\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/i; +const TOOL_CALL_MARKUP_ARGS_TAG_PATTERN = /<(?:[a-z0-9_:-]+:)?(input|arguments|argument|parameters|parameter|args|params)\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/i; +const TOOL_CALL_MARKUP_KV_PATTERN = /<(?:[a-z0-9_:-]+:)?([a-z0-9_.-]+)\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?\1>/gi; +const TOOL_CALL_MARKUP_ATTR_PATTERN = /(name|function|tool)\s*=\s*"([^"]+)"/i; const { toStringSafe, @@ -103,6 +109,104 @@ function parseToolCallsPayload(payload) { return one ? [one] : []; } +function parseMarkupToolCalls(text) { + const raw = toStringSafe(text).trim(); + if (!raw) { + return []; + } + const out = []; + for (const m of raw.matchAll(TOOL_CALL_MARKUP_BLOCK_PATTERN)) { + const parsed = parseMarkupSingleToolCall(toStringSafe(m[2]).trim(), toStringSafe(m[3]).trim()); + if (parsed) { + out.push(parsed); + } + } + for (const m of raw.matchAll(TOOL_CALL_MARKUP_SELFCLOSE_PATTERN)) { + const parsed = parseMarkupSingleToolCall(toStringSafe(m[1]).trim(), ''); + if (parsed) { + out.push(parsed); + } + } + return out; +} + +function parseMarkupSingleToolCall(attrs, inner) { + const embedded = parseToolCallsPayload(inner); + if (embedded.length > 0) { + return embedded[0]; + } + let name = ''; + const attrMatch = attrs.match(TOOL_CALL_MARKUP_ATTR_PATTERN); + if (attrMatch && attrMatch[2]) { + name = toStringSafe(attrMatch[2]).trim(); + } + if (!name) { + const m = inner.match(TOOL_CALL_MARKUP_NAME_TAG_PATTERN); + if (m && m[2]) { + name = stripTagText(m[2]); + } + } + if (!name) { + return null; + } + + let input = {}; + const argsMatch = inner.match(TOOL_CALL_MARKUP_ARGS_TAG_PATTERN); + if (argsMatch && argsMatch[2]) { + input = parseMarkupInput(argsMatch[2]); + } else { + const kv = parseMarkupKVObject(inner); + if (Object.keys(kv).length > 0) { + input = kv; + } + } + return { name, input }; +} + +function parseMarkupInput(raw) { + const s = toStringSafe(raw).trim(); + if (!s) { + return {}; + } + const parsed = parseToolCallInput(s); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length > 0) { + return parsed; + } + const kv = parseMarkupKVObject(s); + if (Object.keys(kv).length > 0) { + return kv; + } + return { _raw: stripTagText(s) }; +} + +function parseMarkupKVObject(text) { + const raw = toStringSafe(text).trim(); + if (!raw) { + return {}; + } + const out = {}; + for (const m of raw.matchAll(TOOL_CALL_MARKUP_KV_PATTERN)) { + const key = toStringSafe(m[1]).trim(); + if (!key) { + continue; + } + const valueRaw = stripTagText(m[2]); + if (!valueRaw) { + continue; + } + try { + out[key] = JSON.parse(valueRaw); + } catch (_err) { + out[key] = valueRaw; + } + } + return out; +} + +function stripTagText(text) { + return toStringSafe(text).replace(/<[^>]+>/g, ' ').trim(); +} + function parseToolCallList(v) { if (!Array.isArray(v)) { return []; @@ -193,4 +297,5 @@ module.exports = { stripFencedCodeBlocks, buildToolCallCandidates, parseToolCallsPayload, + parseMarkupToolCalls, }; diff --git a/internal/util/toolcalls_markup.go b/internal/util/toolcalls_markup.go new file mode 100644 index 0000000..1fef7a7 --- /dev/null +++ b/internal/util/toolcalls_markup.go @@ -0,0 +1,134 @@ +package util + +import ( + "encoding/json" + "regexp" + "strings" +) + +var toolCallMarkupTagNames = []string{"tool_call", "function_call", "invoke"} +var toolCallMarkupTagPatternByName = map[string]*regexp.Regexp{ + "tool_call": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?tool_call\b([^>]*)>(.*?)`), + "function_call": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?function_call\b([^>]*)>(.*?)`), + "invoke": regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)>(.*?)`), +} +var toolCallMarkupSelfClosingPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?invoke\b([^>]*)/>`) +var toolCallMarkupNameTagPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?(?:name|function)\b[^>]*>(.*?)`) +var toolCallMarkupArgsTagPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?(?:input|arguments|argument|parameters|parameter|args|params)\b[^>]*>(.*?)`) +var toolCallMarkupKVPattern = regexp.MustCompile(`(?is)<(?:[a-z0-9_:-]+:)?([a-z0-9_\-.]+)\b[^>]*>(.*?)`) +var toolCallMarkupAttrPattern = regexp.MustCompile(`(?is)(name|function|tool)\s*=\s*"([^"]+)"`) +var anyTagPattern = regexp.MustCompile(`(?is)<[^>]+>`) + +func parseMarkupToolCalls(text string) []ParsedToolCall { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return nil + } + + out := make([]ParsedToolCall, 0) + for _, tagName := range toolCallMarkupTagNames { + pattern := toolCallMarkupTagPatternByName[tagName] + for _, m := range pattern.FindAllStringSubmatch(trimmed, -1) { + if len(m) < 3 { + continue + } + attrs := strings.TrimSpace(m[1]) + inner := strings.TrimSpace(m[2]) + if parsed := parseMarkupSingleToolCall(attrs, inner); parsed.Name != "" { + out = append(out, parsed) + } + } + } + for _, m := range toolCallMarkupSelfClosingPattern.FindAllStringSubmatch(trimmed, -1) { + if len(m) < 2 { + continue + } + if parsed := parseMarkupSingleToolCall(strings.TrimSpace(m[1]), ""); parsed.Name != "" { + out = append(out, parsed) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func parseMarkupSingleToolCall(attrs string, inner string) ParsedToolCall { + if parsed := parseToolCallsPayload(inner); len(parsed) > 0 { + return parsed[0] + } + + name := "" + if m := toolCallMarkupAttrPattern.FindStringSubmatch(attrs); len(m) >= 3 { + name = strings.TrimSpace(m[2]) + } + if name == "" { + if m := toolCallMarkupNameTagPattern.FindStringSubmatch(inner); len(m) >= 2 { + name = strings.TrimSpace(stripTagText(m[1])) + } + } + if name == "" { + return ParsedToolCall{} + } + + input := map[string]any{} + if m := toolCallMarkupArgsTagPattern.FindStringSubmatch(inner); len(m) >= 2 { + input = parseMarkupInput(m[1]) + } else if kv := parseMarkupKVObject(inner); len(kv) > 0 { + input = kv + } + return ParsedToolCall{Name: name, Input: input} +} + +func parseMarkupInput(raw string) map[string]any { + raw = strings.TrimSpace(raw) + if raw == "" { + return map[string]any{} + } + if parsed := parseToolCallInput(raw); len(parsed) > 0 { + return parsed + } + if kv := parseMarkupKVObject(raw); len(kv) > 0 { + return kv + } + return map[string]any{"_raw": stripTagText(raw)} +} + +func parseMarkupKVObject(text string) map[string]any { + matches := toolCallMarkupKVPattern.FindAllStringSubmatch(strings.TrimSpace(text), -1) + if len(matches) == 0 { + return nil + } + out := map[string]any{} + for _, m := range matches { + if len(m) < 4 { + continue + } + key := strings.TrimSpace(m[1]) + endKey := strings.TrimSpace(m[3]) + if key == "" { + continue + } + if !strings.EqualFold(key, endKey) { + continue + } + value := strings.TrimSpace(stripTagText(m[2])) + if value == "" { + continue + } + var jsonValue any + if json.Unmarshal([]byte(value), &jsonValue) == nil { + out[key] = jsonValue + continue + } + out[key] = value + } + if len(out) == 0 { + return nil + } + return out +} + +func stripTagText(text string) string { + return strings.TrimSpace(anyTagPattern.ReplaceAllString(text, "")) +} diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 6e949b1..c3e76bf 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -33,12 +33,16 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa if strings.TrimSpace(text) == "" { return result } - result.SawToolCallSyntax = strings.Contains(strings.ToLower(text), "tool_calls") + result.SawToolCallSyntax = looksLikeToolCallSyntax(text) candidates := buildToolCallCandidates(text) var parsed []ParsedToolCall for _, candidate := range candidates { - if tc := parseToolCallsPayload(candidate); len(tc) > 0 { + tc := parseToolCallsPayload(candidate) + if len(tc) == 0 { + tc = parseMarkupToolCalls(candidate) + } + if len(tc) > 0 { parsed = tc result.SawToolCallSyntax = true break @@ -68,17 +72,18 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) if looksLikeToolExampleContext(trimmed) { return result } - result.SawToolCallSyntax = strings.Contains(strings.ToLower(trimmed), "tool_calls") + result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed) candidates := []string{trimmed} for _, candidate := range candidates { candidate = strings.TrimSpace(candidate) if candidate == "" { continue } - if !strings.HasPrefix(candidate, "{") && !strings.HasPrefix(candidate, "[") { - continue + parsed := parseToolCallsPayload(candidate) + if len(parsed) == 0 { + parsed = parseMarkupToolCalls(candidate) } - if parsed := parseToolCallsPayload(candidate); len(parsed) > 0 { + if len(parsed) > 0 { result.SawToolCallSyntax = true calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames) result.Calls = calls @@ -106,27 +111,32 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin } if len(allowed) == 0 { rejectedSet := map[string]struct{}{} + rejected := make([]string, 0, len(parsed)) for _, tc := range parsed { if tc.Name == "" { continue } + if _, ok := rejectedSet[tc.Name]; ok { + continue + } rejectedSet[tc.Name] = struct{}{} - } - rejected := make([]string, 0, len(rejectedSet)) - for name := range rejectedSet { - rejected = append(rejected, name) + rejected = append(rejected, tc.Name) } return nil, rejected } out := make([]ParsedToolCall, 0, len(parsed)) rejectedSet := map[string]struct{}{} + rejected := make([]string, 0) for _, tc := range parsed { if tc.Name == "" { continue } matchedName := resolveAllowedToolName(tc.Name, allowed, allowedCanonical) if matchedName == "" { - rejectedSet[tc.Name] = struct{}{} + if _, ok := rejectedSet[tc.Name]; !ok { + rejectedSet[tc.Name] = struct{}{} + rejected = append(rejected, tc.Name) + } continue } tc.Name = matchedName @@ -135,10 +145,6 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin } out = append(out, tc) } - rejected := make([]string, 0, len(rejectedSet)) - for name := range rejectedSet { - rejected = append(rejected, name) - } return out, rejected } @@ -186,6 +192,14 @@ func parseToolCallsPayload(payload string) []ParsedToolCall { return nil } +func looksLikeToolCallSyntax(text string) bool { + lower := strings.ToLower(text) + return strings.Contains(lower, "tool_calls") || + strings.Contains(lower, "read_file{\"path\":\"README.MD\"}", + "tool_names": [ + "read_file" + ] +} \ No newline at end of file diff --git a/tests/compat/fixtures/toolcalls/invoke_attr.json b/tests/compat/fixtures/toolcalls/invoke_attr.json new file mode 100644 index 0000000..70c77fc --- /dev/null +++ b/tests/compat/fixtures/toolcalls/invoke_attr.json @@ -0,0 +1,6 @@ +{ + "text": "{\"path\":\"README.MD\"}", + "tool_names": [ + "read_file" + ] +} \ No newline at end of file diff --git a/tests/compat/fixtures/toolcalls/loose_normalize.json b/tests/compat/fixtures/toolcalls/loose_normalize.json new file mode 100644 index 0000000..f4d112e --- /dev/null +++ b/tests/compat/fixtures/toolcalls/loose_normalize.json @@ -0,0 +1,6 @@ +{ + "text": "{\"tool_calls\":[{\"name\":\"read-file\",\"input\":{\"path\":\"README.MD\"}}]}", + "tool_names": [ + "read_file" + ] +} \ No newline at end of file diff --git a/tests/compat/fixtures/toolcalls/namespace_tail_normalize.json b/tests/compat/fixtures/toolcalls/namespace_tail_normalize.json new file mode 100644 index 0000000..67d504d --- /dev/null +++ b/tests/compat/fixtures/toolcalls/namespace_tail_normalize.json @@ -0,0 +1,6 @@ +{ + "text": "{\"tool_calls\":[{\"name\":\"company.fs.read_file\",\"input\":{\"path\":\"README.MD\"}}]}", + "tool_names": [ + "read_file" + ] +} \ No newline at end of file diff --git a/tests/compat/fixtures/toolcalls/xml_tool_call.json b/tests/compat/fixtures/toolcalls/xml_tool_call.json new file mode 100644 index 0000000..279f1a2 --- /dev/null +++ b/tests/compat/fixtures/toolcalls/xml_tool_call.json @@ -0,0 +1,6 @@ +{ + "text": "read_file{\"path\":\"README.MD\"}", + "tool_names": [ + "read_file" + ] +} \ No newline at end of file diff --git a/tests/node/js_compat_test.js b/tests/node/js_compat_test.js index 74b3fd1..c4bdcff 100644 --- a/tests/node/js_compat_test.js +++ b/tests/node/js_compat_test.js @@ -6,7 +6,7 @@ const fs = require('node:fs'); const path = require('node:path'); const chatStream = require('../../api/chat-stream.js'); -const { parseToolCalls, parseStandaloneToolCalls } = require('../../internal/js/helpers/stream-tool-sieve.js'); +const { parseToolCallsDetailed, parseStandaloneToolCallsDetailed } = require('../../internal/js/helpers/stream-tool-sieve.js'); const { parseChunkForContent, estimateTokens } = chatStream.__test; @@ -44,9 +44,12 @@ test('js compat: toolcall fixtures', () => { const fixture = readJSON(path.join(fixtureDir, file)); const expected = readJSON(path.join(expectedDir, `toolcalls_${name}.json`)); const mode = typeof fixture.mode === 'string' ? fixture.mode.trim().toLowerCase() : ''; - const parser = mode === 'standalone' ? parseStandaloneToolCalls : parseToolCalls; + const parser = mode === 'standalone' ? parseStandaloneToolCallsDetailed : parseToolCallsDetailed; const got = parser(fixture.text, fixture.tool_names || []); - assert.deepEqual(got, expected.calls, `${name}: calls mismatch`); + assert.deepEqual(got.calls, expected.calls, `${name}: calls mismatch`); + assert.equal(got.sawToolCallSyntax, expected.sawToolCallSyntax, `${name}: sawToolCallSyntax mismatch`); + assert.equal(got.rejectedByPolicy, expected.rejectedByPolicy, `${name}: rejectedByPolicy mismatch`); + assert.deepEqual(got.rejectedToolNames, expected.rejectedToolNames, `${name}: rejectedToolNames mismatch`); } });