fix: keep invalid Bash tool args from becoming commands
This commit is contained in:
@@ -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<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
test('normalizes plain string Bash tool arguments in streaming responses', async () => {
|
||||||
globalThis.fetch = (async (_input, _init) => {
|
globalThis.fetch = (async (_input, _init) => {
|
||||||
const chunks = makeStreamChunks([
|
const chunks = makeStreamChunks([
|
||||||
@@ -894,6 +954,82 @@ test('normalizes plain string Bash tool arguments when streaming starts with whi
|
|||||||
expect(normalizedInput).toBe('{"command":" pwd"}')
|
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<Record<string, unknown>> = []
|
||||||
|
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<string, unknown>).type === 'input_json_delta',
|
||||||
|
)
|
||||||
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
expect(normalizedInput).toBe('{"raw":" "}')
|
||||||
|
})
|
||||||
|
|
||||||
test('normalizes streaming Bash arguments that begin with bracket syntax', async () => {
|
test('normalizes streaming Bash arguments that begin with bracket syntax', async () => {
|
||||||
globalThis.fetch = (async (_input, _init) => {
|
globalThis.fetch = (async (_input, _init) => {
|
||||||
const chunks = makeStreamChunks([
|
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<string, unknown>).partial_json)
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
expect(streamedInput).toBe('{"cwd":"/tmp"')
|
expect(streamedInput).toBe('{"raw":"{\\"cwd\\":\\"/tmp\\""}')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('preserves raw input for unknown plain string tool arguments', async () => {
|
test('preserves raw input for unknown plain string tool arguments', async () => {
|
||||||
|
|||||||
@@ -707,7 +707,7 @@ async function* openaiStreamToAnthropic(
|
|||||||
)
|
)
|
||||||
if (repairedStructuredJson) {
|
if (repairedStructuredJson) {
|
||||||
partialJson = repairedStructuredJson
|
partialJson = repairedStructuredJson
|
||||||
} else if (!/^\s*\{\s*"/.test(tc.jsonBuffer)) {
|
} else {
|
||||||
partialJson = JSON.stringify(
|
partialJson = JSON.stringify(
|
||||||
normalizeToolArguments(tc.name, tc.jsonBuffer),
|
normalizeToolArguments(tc.name, tc.jsonBuffer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ const STRING_ARGUMENT_TOOL_FIELDS: Record<string, string> = {
|
|||||||
Bash: 'command',
|
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<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
@@ -32,6 +40,9 @@ export function normalizeToolArguments(
|
|||||||
}
|
}
|
||||||
if (toolName === 'Bash') {
|
if (toolName === 'Bash') {
|
||||||
if (typeof parsed === 'string') {
|
if (typeof parsed === 'string') {
|
||||||
|
if (isBlankString(parsed)) {
|
||||||
|
return { raw: parsed }
|
||||||
|
}
|
||||||
return wrapPlainStringToolArguments(toolName, parsed) ?? parsed
|
return wrapPlainStringToolArguments(toolName, parsed) ?? parsed
|
||||||
}
|
}
|
||||||
return wrapPlainStringToolArguments(toolName, rawArguments) ?? rawArguments
|
return wrapPlainStringToolArguments(toolName, rawArguments) ?? rawArguments
|
||||||
@@ -41,6 +52,11 @@ export function normalizeToolArguments(
|
|||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
} catch {
|
} catch {
|
||||||
|
if (toolName === 'Bash') {
|
||||||
|
if (isBlankString(rawArguments) || isLikelyStructuredObjectLiteral(rawArguments)) {
|
||||||
|
return { raw: rawArguments }
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
wrapPlainStringToolArguments(toolName, rawArguments) ?? { raw: rawArguments }
|
wrapPlainStringToolArguments(toolName, rawArguments) ?? { raw: rawArguments }
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user