From 0ad7746b7a7861b4e4a81f86be6aaac5740960c9 Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Mon, 6 Apr 2026 16:28:30 +0530 Subject: [PATCH] fix: keep invalid Bash tool args from becoming commands --- src/services/api/openaiShim.test.ts | 138 +++++++++++++++++- src/services/api/openaiShim.ts | 2 +- src/services/api/toolArgumentNormalization.ts | 16 ++ 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index c8f60cd5..a2b927d9 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -622,6 +622,66 @@ test('normalizes Bash tool arguments that are valid JSON literals', async () => ]) }) +test('keeps terminal empty Bash tool arguments invalid in non-streaming responses', async () => { + globalThis.fetch = (async (_input, _init) => { + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'google/gemini-3.1-pro-preview', + choices: [ + { + message: { + role: 'assistant', + tool_calls: [ + { + id: 'function-call-1', + type: 'function', + function: { + name: 'Bash', + arguments: '', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 4, + total_tokens: 16, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + const message = await client.beta.messages.create({ + model: 'google/gemini-3.1-pro-preview', + system: 'test system', + messages: [{ role: 'user', content: 'Use Bash' }], + max_tokens: 64, + stream: false, + }) as { + content?: Array> + } + + expect(message.content).toEqual([ + { + type: 'tool_use', + id: 'function-call-1', + name: 'Bash', + input: { raw: '' }, + }, + ]) +}) + test('normalizes plain string Bash tool arguments in streaming responses', async () => { globalThis.fetch = (async (_input, _init) => { const chunks = makeStreamChunks([ @@ -894,6 +954,82 @@ test('normalizes plain string Bash tool arguments when streaming starts with whi expect(normalizedInput).toBe('{"command":" pwd"}') }) +test('keeps terminal whitespace-only Bash arguments invalid in streaming responses', async () => { + globalThis.fetch = (async (_input, _init) => { + const chunks = makeStreamChunks([ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'google/gemini-3.1-pro-preview', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: 'function-call-1', + type: 'function', + function: { + name: 'Bash', + arguments: ' ', + }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + model: 'google/gemini-3.1-pro-preview', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'tool_calls', + }, + ], + }, + ]) + + return makeSseResponse(chunks) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + + const result = await client.beta.messages + .create({ + model: 'google/gemini-3.1-pro-preview', + system: 'test system', + messages: [{ role: 'user', content: 'Use Bash' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + + const events: Array> = [] + for await (const event of result.data) { + events.push(event) + } + + const normalizedInput = events + .filter( + event => + event.type === 'content_block_delta' && + typeof event.delta === 'object' && + event.delta !== null && + (event.delta as Record).type === 'input_json_delta', + ) + .map(event => (event.delta as Record).partial_json) + .join('') + + expect(normalizedInput).toBe('{"raw":" "}') +}) + test('normalizes streaming Bash arguments that begin with bracket syntax', async () => { globalThis.fetch = (async (_input, _init) => { const chunks = makeStreamChunks([ @@ -1293,7 +1429,7 @@ test('does not repair truncated Bash objects that do not contain command', async .map(event => (event.delta as Record).partial_json) .join('') - expect(streamedInput).toBe('{"cwd":"/tmp"') + expect(streamedInput).toBe('{"raw":"{\\"cwd\\":\\"/tmp\\""}') }) test('preserves raw input for unknown plain string tool arguments', async () => { diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 6a17f405..8f8247b9 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -707,7 +707,7 @@ async function* openaiStreamToAnthropic( ) if (repairedStructuredJson) { partialJson = repairedStructuredJson - } else if (!/^\s*\{\s*"/.test(tc.jsonBuffer)) { + } else { partialJson = JSON.stringify( normalizeToolArguments(tc.name, tc.jsonBuffer), ) diff --git a/src/services/api/toolArgumentNormalization.ts b/src/services/api/toolArgumentNormalization.ts index f5dcd73e..131a17ec 100644 --- a/src/services/api/toolArgumentNormalization.ts +++ b/src/services/api/toolArgumentNormalization.ts @@ -2,6 +2,14 @@ const STRING_ARGUMENT_TOOL_FIELDS: Record = { Bash: 'command', } +function isBlankString(value: string): boolean { + return value.trim().length === 0 +} + +function isLikelyStructuredObjectLiteral(value: string): boolean { + return /^\s*\{\s*"[^"\\]+"\s*:/.test(value) +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -32,6 +40,9 @@ export function normalizeToolArguments( } if (toolName === 'Bash') { if (typeof parsed === 'string') { + if (isBlankString(parsed)) { + return { raw: parsed } + } return wrapPlainStringToolArguments(toolName, parsed) ?? parsed } return wrapPlainStringToolArguments(toolName, rawArguments) ?? rawArguments @@ -41,6 +52,11 @@ export function normalizeToolArguments( } return parsed } catch { + if (toolName === 'Bash') { + if (isBlankString(rawArguments) || isLikelyStructuredObjectLiteral(rawArguments)) { + return { raw: rawArguments } + } + } return ( wrapPlainStringToolArguments(toolName, rawArguments) ?? { raw: rawArguments } )