mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
* fix(command-code): normalize malformed tool call arguments and fix test assertion handling * fix(command-code): resolve toolName from assistant calls and update version header to 1.15.1 * refactor(command-code): consolidate pre-pass message tool metadata extraction and add unknown fallback test * fix(command-code): fallback unnamed tool calls to unknown to satisfy upstream name validation * fix(db): rename 139_job_registry -> 143 to avoid collision with 139_ccr_blocks release/v3.8.50 owns version 139 (ccr_blocks, #9061). The #9631 job registry cherry-pick (5e5919dcc) landed its migration as 139_job_registry, recreating the version collision that fix21a3cb32fhad already resolved on the standalone branch. The migration runner throws on startup, which makes getDbInstance() fail and every route return 500. Bump the job registry migration to 143 (next free slot; 140 is taken by connection_runtime_state) so the runner stops throwing. The SQL is idempotent (CREATE TABLE IF NOT EXISTS + INSERT OR IGNORE), so DBs that never applied it just pick it up on next boot; no DB can have recorded version 139 as job_registry because the collision always threw before any migration ran. * fix(command-code): emit arguments on tool-result parts to satisfy /alpha/generate schema * fix(command-code): rename tool names colliding with upstream built-ins to satisfy /alpha/generate result normalization The upstream server normalizes tool-call/tool-result parts against its own built-in registry for matching names. A tool named `tool_search` collides with a server-side built-in, so the result is rejected mid-stream with `input[N] missing required field 'arguments'` (verified live: renaming the pair makes the identical request pass; the server pairs each result with the nearest preceding tool-call, so any result following such a call is affected). Rename colliding names consistently on the wire (definitions + calls + results) via a request-scoped toolNameMap, then un-rename on the response path so the client still sees its original tool names.
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const mod = await import("../../open-sse/executors/commandCode.ts");
|
|
|
|
describe("CommandCodeExecutor", () => {
|
|
it("can be instantiated", () => {
|
|
const executor = new mod.CommandCodeExecutor();
|
|
assert.ok(executor);
|
|
});
|
|
|
|
it("can be instantiated with custom provider", () => {
|
|
const executor = new mod.CommandCodeExecutor("custom-provider");
|
|
assert.ok(executor);
|
|
});
|
|
|
|
it("buildUrl returns a string", () => {
|
|
const executor = new mod.CommandCodeExecutor();
|
|
const url = executor.buildUrl();
|
|
assert.ok(typeof url === "string");
|
|
assert.ok(url.includes("generate") && url.includes("commandcode"));
|
|
});
|
|
|
|
it("execute throws when no API key", async () => {
|
|
const executor = new mod.CommandCodeExecutor();
|
|
try {
|
|
await executor.execute({
|
|
model: "test",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: {},
|
|
signal: null,
|
|
});
|
|
assert.fail("Should have thrown");
|
|
} catch (err) {
|
|
assert.ok(err instanceof Error);
|
|
assert.ok(err.message.includes("API key"));
|
|
}
|
|
});
|
|
|
|
it("execute returns result shape with valid key (will fail on fetch)", async () => {
|
|
const executor = new mod.CommandCodeExecutor();
|
|
try {
|
|
const result = await executor.execute({
|
|
model: "test",
|
|
body: { messages: [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: "fake-key" },
|
|
signal: null,
|
|
});
|
|
// If it returns (network error caught), check shape
|
|
assert.ok(result.response instanceof Response);
|
|
assert.ok(typeof result.url === "string");
|
|
assert.ok(typeof result.headers === "object");
|
|
} catch {
|
|
// 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"}' },
|
|
},
|
|
// Invalid JSON string arguments -> defaults to "{}"
|
|
{
|
|
id: "call_invalid",
|
|
type: "function",
|
|
function: { name: "lookup", arguments: "{invalid-json" },
|
|
},
|
|
// Tool call without name -> defaults tool-result toolName to "unknown"
|
|
{
|
|
id: "call_unnamed",
|
|
type: "function",
|
|
function: { arguments: { q: "unnamed" } },
|
|
},
|
|
],
|
|
},
|
|
{ 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" },
|
|
{ role: "tool", tool_call_id: "call_invalid", content: "r5" },
|
|
{ role: "tool", tool_call_id: "call_unnamed", content: "r6" },
|
|
],
|
|
};
|
|
|
|
try {
|
|
await executor.execute({
|
|
model: "test",
|
|
body,
|
|
stream: false,
|
|
credentials: { apiKey: "fake-key" },
|
|
signal: null,
|
|
});
|
|
} 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<Record<string, unknown>>;
|
|
const toolCalls = parts.filter((p) => p.type === "tool-call");
|
|
assert.equal(toolCalls.length, 6, "all six 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"
|
|
);
|
|
assert.equal(
|
|
byId.get("call_invalid").arguments,
|
|
"{}",
|
|
"invalid JSON string arguments -> empty object"
|
|
);
|
|
|
|
const toolMsgs = sentBody.params.messages.filter((m) => m.role === "tool");
|
|
assert.equal(toolMsgs.length, 6, "all 6 tool result messages present");
|
|
const resultByName = new Map(
|
|
toolMsgs.map((m) => {
|
|
const p = (m.content as Array<Record<string, unknown>>)[0];
|
|
return [String(p.toolCallId), String(p.toolName)];
|
|
})
|
|
);
|
|
assert.equal(resultByName.get("call_missing"), "lookup");
|
|
assert.equal(
|
|
resultByName.get("call_unnamed"),
|
|
"unknown",
|
|
"unnamed call falls back to 'unknown'"
|
|
);
|
|
|
|
// /alpha/generate also requires `arguments` on tool-result parts; a
|
|
// missing one is rejected with `input[N] missing required field 'arguments'`
|
|
// (the index landing on the tool message). Echo the paired call's
|
|
// normalized arguments.
|
|
const resultById = new Map(
|
|
toolMsgs.map((m) => {
|
|
const p = (m.content as Array<Record<string, unknown>>)[0];
|
|
return [String(p.toolCallId), p];
|
|
})
|
|
);
|
|
assert.equal(resultById.size, 6, "each tool result maps to its call id");
|
|
for (const p of resultById.values()) {
|
|
assert.equal(
|
|
typeof p.arguments,
|
|
"string",
|
|
`tool-result ${String(p.toolCallId)} must carry a string arguments field`
|
|
);
|
|
const parsed = JSON.parse(p.arguments as string);
|
|
assert.equal(typeof parsed, "object");
|
|
assert.ok(!Array.isArray(parsed), "tool-result arguments must parse to a JSON object");
|
|
}
|
|
assert.equal(
|
|
resultById.get("call_missing").arguments,
|
|
"{}",
|
|
"tool-result echoes paired call's missing arguments as empty object"
|
|
);
|
|
assert.equal(
|
|
resultById.get(pairedId).arguments,
|
|
'{"q":"docs"}',
|
|
"tool-result echoes paired call's object arguments as JSON string"
|
|
);
|
|
assert.equal(
|
|
resultById.get("call_string").arguments,
|
|
'{"q":"string"}',
|
|
"tool-result echoes paired call's valid string arguments as-is"
|
|
);
|
|
assert.equal(
|
|
resultById.get("call_empty").arguments,
|
|
"{}",
|
|
"tool-result echoes paired call's empty arguments as empty object"
|
|
);
|
|
assert.equal(
|
|
resultById.get("call_invalid").arguments,
|
|
"{}",
|
|
"tool-result echoes paired call's invalid JSON arguments as empty object"
|
|
);
|
|
});
|
|
|
|
it("COMMAND_CODE_VERSION default constant is 1.15.1", () => {
|
|
assert.equal(mod.COMMAND_CODE_VERSION, "1.15.1");
|
|
});
|
|
|
|
it("renames tool names colliding with upstream built-ins on the wire and un-renames on the response (#regression input[N] missing required field arguments from a tool_search result)", async () => {
|
|
// Upstream /alpha/generate normalizes tool-call/result parts against its
|
|
// OWN built-in registry for matching names; `tool_search` collides and its
|
|
// result is rejected with `input[N] missing required field 'arguments'`.
|
|
// Verified live: renaming the pair to a non-colliding name passes.
|
|
const body = {
|
|
messages: [
|
|
{ role: "user", content: "hi" },
|
|
{
|
|
role: "assistant",
|
|
content: "",
|
|
tool_calls: [
|
|
{
|
|
id: "call_00_AAAAAAAAAAAAAAAAA",
|
|
type: "function",
|
|
function: { name: "tool_search", arguments: '{"query":"x"}' },
|
|
},
|
|
{
|
|
id: "call_01_BBBBBBBBBBBBBBBBB",
|
|
type: "function",
|
|
function: { name: "lookup", arguments: '{"q":"1"}' },
|
|
},
|
|
],
|
|
},
|
|
{ role: "tool", tool_call_id: "call_00_AAAAAAAAAAAAAAAAA", content: "r1" },
|
|
{ role: "tool", tool_call_id: "call_01_BBBBBBBBBBBBBBBBB", content: "r2" },
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "tool_search",
|
|
description: "search tools",
|
|
parameters: { type: "object", properties: { query: { type: "string" } } },
|
|
},
|
|
},
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "lookup",
|
|
description: "lookup",
|
|
parameters: { type: "object", properties: { q: { type: "string" } } },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const calls: Array<{ url: string; init: RequestInit; body: unknown }> = [];
|
|
const originalFetch = globalThis.fetch;
|
|
// execute() makes a single upstream fetch; capture the wire request and
|
|
// return a stream with a tool-call event using the renamed wire name.
|
|
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)),
|
|
});
|
|
const streamBody =
|
|
'data: {"type":"tool-call","toolCallId":"c1","toolName":"omniroute_tool_search","input":{"query":"x"}}\n\n' +
|
|
'data: {"type":"finish","finishReason":"tool_use"}\n\n' +
|
|
"data: [DONE]\n\n";
|
|
return new Response(streamBody, {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
const executor = new mod.CommandCodeExecutor();
|
|
let result: { response: Response } | null = null;
|
|
try {
|
|
result = await executor.execute({
|
|
model: "test",
|
|
body,
|
|
stream: false,
|
|
credentials: { apiKey: "fake-key" },
|
|
signal: null,
|
|
});
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
const sentBody = calls[0].body as {
|
|
params: {
|
|
messages: Array<{ role: string; content: unknown }>;
|
|
tools: Array<{ name: string }>;
|
|
};
|
|
};
|
|
const toolDefNames = sentBody.params.tools.map((t) => t.name);
|
|
assert.ok(
|
|
toolDefNames.includes("omniroute_tool_search"),
|
|
"colliding tool def renamed on the wire"
|
|
);
|
|
assert.ok(toolDefNames.includes("lookup"), "non-colliding tool def untouched");
|
|
|
|
const assistant = sentBody.params.messages.find((m) => m.role === "assistant");
|
|
const toolCallParts = (assistant?.content as Array<Record<string, unknown>>).filter(
|
|
(p) => p.type === "tool-call"
|
|
);
|
|
const toolSearchCall = toolCallParts.find((p) => p.toolName === "omniroute_tool_search");
|
|
assert.ok(toolSearchCall, "assistant tool-call part renamed on the wire");
|
|
const lookupCall = toolCallParts.find((p) => p.toolName === "lookup");
|
|
assert.ok(lookupCall, "non-colliding tool-call part untouched");
|
|
|
|
const toolMsgs = sentBody.params.messages.filter((m) => m.role === "tool");
|
|
const toolSearchResult = toolMsgs.find(
|
|
(m) => (m.content as Array<Record<string, unknown>>)[0]?.toolName === "omniroute_tool_search"
|
|
);
|
|
assert.ok(toolSearchResult, "tool-result part renamed on the wire");
|
|
|
|
// Response path: upstream emits the renamed wire name; the client must get
|
|
// its original name back.
|
|
assert.ok(result, "execute returned a response");
|
|
const json = (await result.response.json()) as {
|
|
choices: Array<{ message: { tool_calls?: Array<{ function: { name: string } }> } }>;
|
|
};
|
|
const toolCalls = json.choices[0].message.tool_calls ?? [];
|
|
assert.equal(toolCalls.length, 1, "one tool call translated");
|
|
assert.equal(
|
|
toolCalls[0].function.name,
|
|
"tool_search",
|
|
"renamed wire name un-renamed for the client"
|
|
);
|
|
});
|
|
});
|