From 94cf1bfcc77a5c131e7ee508c243908d271b8d21 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sat, 7 Mar 2026 14:45:10 +0800 Subject: [PATCH] drop nameless assistant tool history entries --- internal/adapter/openai/message_normalize.go | 2 +- .../adapter/openai/message_normalize_test.go | 23 ++++++++++++++++ .../js/helpers/stream-tool-sieve/sieve.js | 20 +++++--------- tests/node/stream-tool-sieve.test.js | 27 ++++++++++++++----- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/internal/adapter/openai/message_normalize.go b/internal/adapter/openai/message_normalize.go index 724cb9f..c4f4c4a 100644 --- a/internal/adapter/openai/message_normalize.go +++ b/internal/adapter/openai/message_normalize.go @@ -78,7 +78,7 @@ func formatAssistantToolCallsForPrompt(msg map[string]any, traceID string) strin args = normalizeOpenAIArgumentsForPrompt(fn["arguments"]) } if name == "" { - name = "unknown" + continue } if args == "" { args = normalizeOpenAIArgumentsForPrompt(call["arguments"]) diff --git a/internal/adapter/openai/message_normalize_test.go b/internal/adapter/openai/message_normalize_test.go index ecb3bbd..c9c967d 100644 --- a/internal/adapter/openai/message_normalize_test.go +++ b/internal/adapter/openai/message_normalize_test.go @@ -194,6 +194,29 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t * } } + +func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDropped(t *testing.T) { + raw := []any{ + map[string]any{ + "role": "assistant", + "tool_calls": []any{ + map[string]any{ + "id": "call_missing_name", + "type": "function", + "function": map[string]any{ + "arguments": `{"path":"README.MD"}`, + }, + }, + }, + }, + } + + normalized := normalizeOpenAIMessagesForPrompt(raw, "") + if len(normalized) != 0 { + t.Fatalf("expected nameless assistant tool_calls to be dropped, got %#v", normalized) + } +} + func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLiteral(t *testing.T) { raw := []any{ map[string]any{ diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index 1f1fc59..c1b92a8 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -21,22 +21,14 @@ function processToolSieveChunk(state, chunk, toolNames) { } const events = []; - if (Array.isArray(state.pendingToolCalls) && state.pendingToolCalls.length > 0) { - const pending = state.pending || ''; - if (pending.trim() !== '') { - const content = (state.pendingToolRaw || '') + pending; - state.pending = ''; - state.pendingToolRaw = ''; - state.pendingToolCalls = []; - noteText(state, content); - events.push({ type: 'text', text: content }); - } else { - return events; - } - } - // eslint-disable-next-line no-constant-condition while (true) { + if (Array.isArray(state.pendingToolCalls) && state.pendingToolCalls.length > 0) { + events.push({ type: 'tool_calls', calls: state.pendingToolCalls }); + state.pendingToolRaw = ''; + state.pendingToolCalls = []; + continue; + } if (state.capturing) { if (state.pending) { state.capture += state.pending; diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index e68f2ff..0b67d6e 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -109,7 +109,23 @@ test('parseStandaloneToolCalls ignores fenced code block tool_call examples', () assert.equal(calls.length, 0); }); -test('sieve keeps late key convergence payload as plain text in strict mode', () => { + +test('sieve emits tool_calls in the same chunk processing tick once payload is complete', () => { + const state = createToolSieveState(); + const first = processToolSieveChunk(state, '{"', ['read_file']); + const second = processToolSieveChunk( + state, + 'tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}', + ['read_file'], + ); + const firstCalls = first.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); + const secondCalls = second.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); + assert.equal(firstCalls.length, 0); + assert.equal(secondCalls.length, 1); + assert.equal(secondCalls[0].name, 'read_file'); +}); + +test('sieve emits tool_calls when late key convergence forms a complete payload', () => { const events = runSieve( [ '{"', @@ -119,12 +135,11 @@ test('sieve keeps late key convergence payload as plain text in strict mode', () ['read_file'], ); const leakedText = collectText(events); - const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && Array.isArray(evt.calls) && evt.calls.length > 0); - const hasToolDelta = events.some((evt) => evt.type === 'tool_call_deltas' && Array.isArray(evt.deltas) && evt.deltas.length > 0); - assert.equal(hasToolCall || hasToolDelta, false); - assert.equal(leakedText.includes('{'), true); - assert.equal(leakedText.toLowerCase().includes('tool_calls'), true); + const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); + assert.equal(finalCalls.length, 1); + assert.equal(finalCalls[0].name, 'read_file'); assert.equal(leakedText.includes('后置正文C。'), true); + assert.equal(leakedText.toLowerCase().includes('tool_calls'), false); }); test('sieve keeps embedded invalid tool-like json as normal text to avoid stream stalls', () => {