From 9f8755a05b594401c6440eeec8b77fbe2fd91933 Mon Sep 17 00:00:00 2001 From: Choti Wongbussakorn <126886556+Chewji9875@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:26:04 +0700 Subject: [PATCH] fix(command-code): include tool call arguments --- open-sse/executors/commandCode.ts | 21 ++++- tests/unit/executor-command-code.test.ts | 101 +++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index cc2642de5c..53c165317f 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -48,6 +48,21 @@ function recordOrEmpty(value: unknown): JsonRecord { return {}; } +/** + * Build the `arguments` field for an assistant tool-call part that Command + * Code's /alpha/generate schema REQUIRES (rejects a missing field with + * `missing required field 'arguments'`). Valid source values round-trip: + * - object arguments -> JSON string of the object + * - string arguments -> the string as-is (already valid JSON) + * - missing / empty / invalid JSON -> "{}" (a valid empty-object string) + */ +function toolCallArgumentsString(value: unknown): string { + const parsed = recordOrEmpty(value); + if (isRecord(value)) return JSON.stringify(parsed); + if (typeof value === "string" && value.trim()) return value; + return JSON.stringify(parsed); +} + function normalizeContentText(content: unknown): string { if (typeof content === "string") return content; return asRecordArray(content) @@ -244,11 +259,15 @@ function convertMessages( const id = stringValue(call.id) || ""; if (!id || !pairedToolCallIds.has(id)) continue; const fn = isRecord(call.function) ? call.function : {}; + const parsedInput = recordOrEmpty(fn.arguments); parts.push({ type: "tool-call", toolCallId: id, toolName: stringValue(fn.name) || "", - input: recordOrEmpty(fn.arguments), + input: parsedInput, + // /alpha/generate requires this field on assistant tool-call parts; + // a missing one is rejected with `missing required field 'arguments'`. + arguments: toolCallArgumentsString(fn.arguments), }); } diff --git a/tests/unit/executor-command-code.test.ts b/tests/unit/executor-command-code.test.ts index d4c2761d56..8fb9984a04 100644 --- a/tests/unit/executor-command-code.test.ts +++ b/tests/unit/executor-command-code.test.ts @@ -56,4 +56,105 @@ describe("CommandCodeExecutor", () => { // Network error is expected in test environment } }); + + it("assistant tool-call conversion always emits a valid required arguments field (#regression input[N] missing required field arguments)", async () => { + const calls: Array<{ url: string; init: RequestInit; body: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(url), + init: init || {}, + body: JSON.parse(String((init as RequestInit | undefined)?.body)), + }); + return new Response("", { status: 200 }); + }) as typeof fetch; + + const executor = new mod.CommandCodeExecutor(); + const pairedId = "call_paired"; + const body = { + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: "", + tool_calls: [ + // Missing arguments entirely -> must still get a valid arguments field + { id: "call_missing", type: "function", function: { name: "lookup" } }, + // Empty string arguments -> "{}" + { + id: "call_empty", + type: "function", + function: { name: "lookup", arguments: "" }, + }, + // Valid object arguments -> round-trips as JSON string + { + id: pairedId, + type: "function", + function: { name: "lookup", arguments: { q: "docs" } }, + }, + // Valid string arguments -> preserved as-is + { + id: "call_string", + type: "function", + function: { name: "lookup", arguments: '{"q":"string"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_missing", content: "r1" }, + { role: "tool", tool_call_id: "call_empty", content: "r2" }, + { role: "tool", tool_call_id: pairedId, content: "r3" }, + { role: "tool", tool_call_id: "call_string", content: "r4" }, + ], + }; + + try { + await executor.execute({ + model: "test", + body, + stream: false, + credentials: { apiKey: "fake-key" }, + signal: null, + }); + assert.fail("Expected fetch to reject (no real network)"); + } catch { + // Fetch rejection is expected; inspect the captured body + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(calls.length, 1, "exactly one upstream call"); + const sentBody = calls[0].body as { + params: { messages: Array<{ role: string; content: unknown }> }; + }; + const assistant = sentBody.params.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "assistant turn present"); + const parts = assistant.content as Array>; + const toolCalls = parts.filter((p) => p.type === "tool-call"); + assert.equal(toolCalls.length, 4, "all four paired tool calls converted"); + + for (const call of toolCalls) { + assert.equal( + typeof call.arguments, + "string", + `tool-call ${String(call.toolCallId)} must carry a string arguments field` + ); + const parsed = JSON.parse(call.arguments as string); + assert.equal(typeof parsed, "object"); + assert.ok(!Array.isArray(parsed), "arguments must parse to a JSON object"); + } + + const byId = new Map(toolCalls.map((c) => [String(c.toolCallId), c])); + assert.equal(byId.get("call_missing").arguments, "{}", "missing arguments -> empty object"); + assert.equal(byId.get("call_empty").arguments, "{}", "empty string arguments -> empty object"); + assert.equal( + byId.get(pairedId).arguments, + '{"q":"docs"}', + "object arguments round-trip as JSON string" + ); + assert.equal( + byId.get("call_string").arguments, + '{"q":"string"}', + "valid string arguments preserved as-is" + ); + }); });