602 lines
24 KiB
TypeScript
602 lines
24 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
resolveRequestedModel,
|
|
normalizeCursorModelId,
|
|
encodeAgentRunRequest,
|
|
buildAgentRequestBody,
|
|
iterateConnectFrames,
|
|
decodeAgentServerMessage,
|
|
flattenMessages,
|
|
wrapConnectFrame,
|
|
encodeExecReadRejected,
|
|
encodeExecWriteRejected,
|
|
encodeExecDeleteRejected,
|
|
encodeExecLsRejected,
|
|
encodeExecShellRejected,
|
|
encodeExecBackgroundShellSpawnRejected,
|
|
encodeExecGrepError,
|
|
encodeExecFetchError,
|
|
encodeExecWriteShellStdinError,
|
|
encodeExecDiagnosticsResult,
|
|
encodeExecMcpResult,
|
|
encodeExecMcpError,
|
|
encodeKvGetBlobResult,
|
|
encodeKvSetBlobResult,
|
|
encodeMcpToolDefinitionBody,
|
|
jsonSchemaToProtobufValue,
|
|
encodeRequestContextResponse,
|
|
openAIToolsToMcpDefs,
|
|
} from "../../open-sse/utils/cursorAgentProtobuf";
|
|
|
|
test("resolveRequestedModel maps cursor-agent's client-side aliases", () => {
|
|
assert.deepEqual(resolveRequestedModel("auto"), { modelId: "default", parameters: [] });
|
|
assert.deepEqual(resolveRequestedModel("composer-2-fast"), {
|
|
modelId: "composer-2",
|
|
parameters: [{ id: "fast", value: "true" }],
|
|
});
|
|
assert.deepEqual(resolveRequestedModel("claude-4.6-sonnet-medium"), {
|
|
modelId: "claude-4.6-sonnet-medium",
|
|
parameters: [],
|
|
});
|
|
assert.deepEqual(resolveRequestedModel("composer-2"), { modelId: "composer-2", parameters: [] });
|
|
});
|
|
|
|
test("normalizeCursorModelId canonicalizes composer spelling variants", () => {
|
|
// Known-equivalent spellings cursor would otherwise reject.
|
|
assert.equal(normalizeCursorModelId("composer-2-5"), "composer-2.5");
|
|
assert.equal(normalizeCursorModelId("composer-2.5-sdk"), "composer-2.5");
|
|
assert.equal(normalizeCursorModelId("composer-latest"), "composer-2.5");
|
|
assert.equal(normalizeCursorModelId("COMPOSER-2-5"), "composer-2.5"); // case-insensitive
|
|
assert.equal(normalizeCursorModelId(" composer-latest "), "composer-2.5"); // trimmed
|
|
assert.equal(normalizeCursorModelId(""), "composer-2.5"); // empty → default model
|
|
assert.equal(normalizeCursorModelId("composer-2-5-fast"), "composer-2.5-fast");
|
|
// Canonical and unrelated ids pass through verbatim (no behavior change).
|
|
assert.equal(normalizeCursorModelId("composer-2.5"), "composer-2.5");
|
|
assert.equal(normalizeCursorModelId("composer-2.5-fast"), "composer-2.5-fast");
|
|
assert.equal(normalizeCursorModelId("auto"), "auto");
|
|
assert.equal(normalizeCursorModelId("claude-4.6-sonnet-medium"), "claude-4.6-sonnet-medium");
|
|
});
|
|
|
|
test("resolveRequestedModel normalizes variants then applies auto/-fast rules", () => {
|
|
// Variant of composer-2.5 → canonical id, no parameters.
|
|
assert.deepEqual(resolveRequestedModel("composer-2-5"), {
|
|
modelId: "composer-2.5",
|
|
parameters: [],
|
|
});
|
|
// Variant of the fast model → split into base id + fast parameter.
|
|
assert.deepEqual(resolveRequestedModel("composer-2-5-fast"), {
|
|
modelId: "composer-2.5",
|
|
parameters: [{ id: "fast", value: "true" }],
|
|
});
|
|
// Empty model id resolves to the working default rather than a server reject.
|
|
assert.deepEqual(resolveRequestedModel(""), { modelId: "composer-2.5", parameters: [] });
|
|
});
|
|
|
|
// ─── decode bounds hardening (malformed/hostile wire data) ──────────────────
|
|
|
|
test("decodeAgentServerMessage throws on a length-delimited field that overruns the buffer", () => {
|
|
function v(n: number): Buffer {
|
|
const out: number[] = [];
|
|
while (n > 0x7f) {
|
|
out.push((n & 0x7f) | 0x80);
|
|
n >>>= 7;
|
|
}
|
|
out.push(n);
|
|
return Buffer.from(out);
|
|
}
|
|
function tag(field: number, wt: number) {
|
|
return v((field << 3) | wt);
|
|
}
|
|
// field 1 (LEN) declaring length 200, but only 3 payload bytes follow.
|
|
// Before hardening Buffer.subarray silently clamped to EOF, decoding a
|
|
// truncated message as if it were complete; now checkedLen rejects it so
|
|
// processFrame skips the corrupt frame instead of acting on partial data.
|
|
const malformed = Buffer.concat([tag(1, 2), v(200), Buffer.from([1, 2, 3])]);
|
|
assert.throws(() => decodeAgentServerMessage(malformed), /overruns buffer/);
|
|
});
|
|
|
|
test("decode bounds hardening does not affect well-formed frames", () => {
|
|
// A correctly-sized frame still decodes (regression guard for the new check).
|
|
function v(n: number): Buffer {
|
|
const out: number[] = [];
|
|
while (n > 0x7f) {
|
|
out.push((n & 0x7f) | 0x80);
|
|
n >>>= 7;
|
|
}
|
|
out.push(n);
|
|
return Buffer.from(out);
|
|
}
|
|
function tag(field: number, wt: number) {
|
|
return v((field << 3) | wt);
|
|
}
|
|
function lp(field: number, payload: Buffer) {
|
|
return Buffer.concat([tag(field, 2), v(payload.length), payload]);
|
|
}
|
|
const tdu = lp(1, Buffer.from("ok", "utf8"));
|
|
const iu = lp(1, tdu);
|
|
const asm = lp(1, iu);
|
|
assert.deepEqual(decodeAgentServerMessage(asm), [{ kind: "text", text: "ok" }]);
|
|
});
|
|
|
|
test("encodeAgentRunRequest embeds user text and resolves the model id", () => {
|
|
const buf = encodeAgentRunRequest({
|
|
modelId: "auto",
|
|
userText: "say only PING",
|
|
conversationId: "00000000-0000-0000-0000-000000000000",
|
|
messageId: "11111111-1111-1111-1111-111111111111",
|
|
});
|
|
// Verifiable substrings: cursor-agent itself emits these on the wire.
|
|
const text = buf.toString("latin1");
|
|
assert.ok(text.includes("say only PING"), "user text present");
|
|
assert.ok(text.includes("default"), "auto rewritten to 'default'");
|
|
assert.ok(text.includes("00000000-0000-0000-0000-000000000000"), "conversation id present");
|
|
});
|
|
|
|
test("encodeAgentRunRequest emits composer parameters", () => {
|
|
const buf = encodeAgentRunRequest({
|
|
modelId: "composer-2-fast",
|
|
userText: "hi",
|
|
});
|
|
const text = buf.toString("latin1");
|
|
assert.ok(text.includes("composer-2"), "split model id present");
|
|
assert.ok(text.includes("fast"), "parameter id 'fast' present");
|
|
assert.ok(text.includes("true"), "parameter value 'true' present");
|
|
});
|
|
|
|
test("encodeAgentRunRequest sends ModelDetails for pinned thinking models (#3714)", () => {
|
|
// #3714: pinned Claude/GPT thinking variants returned an empty turn when sent only via
|
|
// RequestedModel (field 9, bare model_id). cursor-agent's working wire format also
|
|
// carries a ModelDetails envelope with model_id + display_model_id + display_name.
|
|
const modelId = "claude-opus-4-7-thinking-xhigh";
|
|
const buf = encodeAgentRunRequest({ modelId, userText: "hi" });
|
|
const occurrences = buf.toString("latin1").split(modelId).length - 1;
|
|
// RequestedModel.model_id (1) + ModelDetails {model_id, display_model_id, display_name}
|
|
// (3) → the id must now appear at least 4 times (it appeared once before the fix).
|
|
assert.ok(
|
|
occurrences >= 4,
|
|
`pinned model id must be encoded in both RequestedModel and ModelDetails (got ${occurrences})`
|
|
);
|
|
});
|
|
|
|
test("encodeAgentRunRequest keeps RequestedModel + parameters alongside ModelDetails (#3714)", () => {
|
|
// The ModelDetails addition is additive: server-routed ids and the -fast parameter
|
|
// path (carried only by RequestedModel) must be unaffected.
|
|
const composer = encodeAgentRunRequest({ modelId: "composer-2-fast", userText: "hi" });
|
|
const composerText = composer.toString("latin1");
|
|
assert.ok(composerText.includes("composer-2"), "split model id still present");
|
|
assert.ok(composerText.includes("fast"), "'-fast' parameter id still present (RequestedModel)");
|
|
assert.ok(composerText.includes("true"), "'-fast' parameter value still present (RequestedModel)");
|
|
|
|
// auto → default, now appearing in both RequestedModel and ModelDetails.
|
|
const auto = encodeAgentRunRequest({ modelId: "auto", userText: "hi" });
|
|
const autoOccurrences = auto.toString("latin1").split("default").length - 1;
|
|
assert.ok(
|
|
autoOccurrences >= 4,
|
|
`'default' must appear in RequestedModel + ModelDetails (got ${autoOccurrences})`
|
|
);
|
|
});
|
|
|
|
test("buildAgentRequestBody wraps the message in a Connect-RPC frame", () => {
|
|
const buf = buildAgentRequestBody({ modelId: "claude-4.6-sonnet-medium", userText: "hi" });
|
|
// First byte is flags (0x00 = uncompressed); next 4 bytes are big-endian length.
|
|
assert.equal(buf[0], 0x00);
|
|
const length = buf.readUInt32BE(1);
|
|
assert.equal(length, buf.length - 5);
|
|
});
|
|
|
|
test("iterateConnectFrames + decodeAgentServerMessage extract a text delta", () => {
|
|
// Synthesize a server-side delta frame: AgentServerMessage { interaction_update {
|
|
// text_delta { text = "hello" } } }
|
|
function v(n: number): Buffer {
|
|
const out: number[] = [];
|
|
while (n > 0x7f) {
|
|
out.push((n & 0x7f) | 0x80);
|
|
n >>>= 7;
|
|
}
|
|
out.push(n);
|
|
return Buffer.from(out);
|
|
}
|
|
function tag(field: number, wt: number) {
|
|
return v((field << 3) | wt);
|
|
}
|
|
function lenPrefixed(field: number, payload: Buffer) {
|
|
return Buffer.concat([tag(field, 2), v(payload.length), payload]);
|
|
}
|
|
const textDelta = Buffer.from("hello", "utf8");
|
|
const tdu = lenPrefixed(1, textDelta); // TextDeltaUpdate { text }
|
|
const iu = lenPrefixed(1, tdu); // InteractionUpdate { text_delta }
|
|
const asm = lenPrefixed(1, iu); // AgentServerMessage { interaction_update }
|
|
const framed = wrapConnectFrame(asm);
|
|
|
|
const frames = [...iterateConnectFrames(framed)];
|
|
assert.equal(frames.length, 1);
|
|
const deltas = decodeAgentServerMessage(frames[0].payload);
|
|
assert.deepEqual(deltas, [{ kind: "text", text: "hello" }]);
|
|
});
|
|
|
|
test("flattenMessages handles a simple single user message", () => {
|
|
assert.equal(flattenMessages([{ role: "user", content: "hello there" }]), "hello there");
|
|
});
|
|
|
|
test("flattenMessages prepends system prompts and labels multi-turn", () => {
|
|
const out = flattenMessages([
|
|
{ role: "system", content: "be brief" },
|
|
{ role: "user", content: "hi" },
|
|
{ role: "assistant", content: "hello" },
|
|
{ role: "user", content: "again" },
|
|
]);
|
|
assert.ok(out.startsWith("be brief"));
|
|
assert.ok(out.includes("User: hi"));
|
|
assert.ok(out.includes("Assistant: hello"));
|
|
assert.ok(out.includes("User: again"));
|
|
});
|
|
|
|
test("flattenMessages flattens content arrays", () => {
|
|
const out = flattenMessages([
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "part1" },
|
|
{ type: "text", text: "part2" },
|
|
],
|
|
},
|
|
]);
|
|
assert.equal(out, "part1\npart2");
|
|
});
|
|
|
|
// ─── Phase 1: rejection encoders ───────────────────────────────────────────
|
|
//
|
|
// All ExecClientMessage frames share a structure:
|
|
// wrapConnectFrame(
|
|
// AgentClientMessage {
|
|
// exec_client_message (2): ExecClientMessage {
|
|
// id (1): execMsgId,
|
|
// exec_id (15): execId,
|
|
// <result_field>: <result_payload>,
|
|
// }
|
|
// }
|
|
// )
|
|
//
|
|
// We unwrap via decodeFields and assert: (1) framing is valid, (2) the
|
|
// ExecClientMessage carries the right id+exec_id, (3) the result field
|
|
// number matches the variant, (4) the payload bytes contain the rejection
|
|
// reason as a substring (sufficient since Buffer.from(reason, "utf8") is
|
|
// embedded verbatim by encodeString).
|
|
|
|
function unwrapFrame(buf: Buffer): Buffer {
|
|
const frames = [...iterateConnectFrames(buf)];
|
|
assert.equal(frames.length, 1);
|
|
return frames[0].payload;
|
|
}
|
|
|
|
function assertEcmShape(
|
|
framed: Buffer,
|
|
expectedExecMsgId: number,
|
|
expectedExecId: string,
|
|
expectedResultField: number
|
|
) {
|
|
const payload = unwrapFrame(framed);
|
|
// Top-level: AgentClientMessage { exec_client_message (2): ECM }
|
|
// Just verify the fragment contains the exec id and result-field tag.
|
|
assert.ok(payload.includes(Buffer.from(expectedExecId, "utf8")), "exec_id present");
|
|
// Tag for result field with WT_LEN (2): (field<<3)|2
|
|
const resultTag = (expectedResultField << 3) | 2;
|
|
let found = false;
|
|
for (let i = 0; i < payload.length; i++) {
|
|
if (payload[i] === resultTag) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(found, `result tag for field ${expectedResultField} present`);
|
|
void expectedExecMsgId; // varint encoded; covered by other tests
|
|
}
|
|
|
|
test("encodeExecReadRejected wraps in read_result (field 7) with reason", () => {
|
|
const framed = encodeExecReadRejected(42, "exec-abc", "/etc/passwd", "denied");
|
|
assertEcmShape(framed, 42, "exec-abc", 7);
|
|
const payload = unwrapFrame(framed);
|
|
assert.ok(payload.includes(Buffer.from("/etc/passwd", "utf8")));
|
|
assert.ok(payload.includes(Buffer.from("denied", "utf8")));
|
|
});
|
|
|
|
test("encodeExecWriteRejected wraps in write_result (field 3)", () => {
|
|
const framed = encodeExecWriteRejected(1, "x", "/tmp/foo", "no");
|
|
assertEcmShape(framed, 1, "x", 3);
|
|
});
|
|
|
|
test("encodeExecDeleteRejected wraps in delete_result (field 4)", () => {
|
|
const framed = encodeExecDeleteRejected(1, "x", "/tmp/foo", "no");
|
|
assertEcmShape(framed, 1, "x", 4);
|
|
});
|
|
|
|
test("encodeExecLsRejected wraps in ls_result (field 8)", () => {
|
|
const framed = encodeExecLsRejected(1, "x", "/tmp", "no");
|
|
assertEcmShape(framed, 1, "x", 8);
|
|
});
|
|
|
|
test("encodeExecShellRejected carries command and working directory", () => {
|
|
const framed = encodeExecShellRejected(7, "exec-7", "rm -rf /", "/home", "denied");
|
|
assertEcmShape(framed, 7, "exec-7", 2);
|
|
const payload = unwrapFrame(framed);
|
|
assert.ok(payload.includes(Buffer.from("rm -rf /", "utf8")));
|
|
assert.ok(payload.includes(Buffer.from("/home", "utf8")));
|
|
assert.ok(payload.includes(Buffer.from("denied", "utf8")));
|
|
});
|
|
|
|
test("encodeExecBackgroundShellSpawnRejected uses field 16", () => {
|
|
const framed = encodeExecBackgroundShellSpawnRejected(1, "x", "sleep", "/", "no");
|
|
assertEcmShape(framed, 1, "x", 16);
|
|
});
|
|
|
|
test("encodeExecGrepError uses field 5 with error message", () => {
|
|
const framed = encodeExecGrepError(1, "exec-grep", "regex too complex");
|
|
assertEcmShape(framed, 1, "exec-grep", 5);
|
|
const payload = unwrapFrame(framed);
|
|
assert.ok(payload.includes(Buffer.from("regex too complex", "utf8")));
|
|
});
|
|
|
|
test("encodeExecFetchError uses field 20 with url and error", () => {
|
|
const framed = encodeExecFetchError(1, "x", "https://example.com", "timeout");
|
|
assertEcmShape(framed, 1, "x", 20);
|
|
const payload = unwrapFrame(framed);
|
|
assert.ok(payload.includes(Buffer.from("https://example.com", "utf8")));
|
|
assert.ok(payload.includes(Buffer.from("timeout", "utf8")));
|
|
});
|
|
|
|
test("encodeExecWriteShellStdinError uses field 23", () => {
|
|
const framed = encodeExecWriteShellStdinError(1, "x", "no shell");
|
|
assertEcmShape(framed, 1, "x", 23);
|
|
});
|
|
|
|
test("encodeExecDiagnosticsResult is empty success on field 9", () => {
|
|
const framed = encodeExecDiagnosticsResult(1, "exec-diag");
|
|
assertEcmShape(framed, 1, "exec-diag", 9);
|
|
});
|
|
|
|
// ─── Phase 1: MCP encoders ──────────────────────────────────────────────────
|
|
|
|
test("encodeExecMcpResult wraps text content in mcp_result (field 11)", () => {
|
|
const framed = encodeExecMcpResult(1, "exec-mcp", "tool output", false);
|
|
assertEcmShape(framed, 1, "exec-mcp", 11);
|
|
const payload = unwrapFrame(framed);
|
|
assert.ok(payload.includes(Buffer.from("tool output", "utf8")));
|
|
});
|
|
|
|
test("encodeExecMcpResult sets is_error when isError=true", () => {
|
|
const framed = encodeExecMcpResult(1, "x", "err msg", true);
|
|
// is_error field would be encoded as varint 1 — verify framing only
|
|
assertEcmShape(framed, 1, "x", 11);
|
|
});
|
|
|
|
test("encodeExecMcpError wraps error in mcp_result", () => {
|
|
const framed = encodeExecMcpError(1, "exec-err", "tool crashed");
|
|
assertEcmShape(framed, 1, "exec-err", 11);
|
|
const payload = unwrapFrame(framed);
|
|
assert.ok(payload.includes(Buffer.from("tool crashed", "utf8")));
|
|
});
|
|
|
|
// ─── Phase 1: KV blob encoders ──────────────────────────────────────────────
|
|
|
|
test("encodeKvGetBlobResult wraps in kv_client_message (field 3)", () => {
|
|
const framed = encodeKvGetBlobResult(99, Buffer.from("blob-content", "utf8"));
|
|
const payload = unwrapFrame(framed);
|
|
// Top-level field 3 (kv_client_message) tag = (3<<3)|2 = 26
|
|
assert.equal(payload[0], 26);
|
|
assert.ok(payload.includes(Buffer.from("blob-content", "utf8")));
|
|
});
|
|
|
|
test("encodeKvSetBlobResult is an empty ack", () => {
|
|
const framed = encodeKvSetBlobResult(7);
|
|
const payload = unwrapFrame(framed);
|
|
assert.equal(payload[0], 26); // (3<<3)|2 = kv_client_message
|
|
});
|
|
|
|
// ─── Phase 1: MCP tool definition body ──────────────────────────────────────
|
|
|
|
test("encodeMcpToolDefinitionBody encodes name, description, schema bytes", () => {
|
|
const body = encodeMcpToolDefinitionBody({
|
|
name: "get_weather",
|
|
description: "Look up current weather",
|
|
inputSchemaBytes: Buffer.from("schema-bytes"),
|
|
providerIdentifier: "omniroute",
|
|
toolName: "get_weather",
|
|
});
|
|
assert.ok(body.includes(Buffer.from("get_weather", "utf8")));
|
|
assert.ok(body.includes(Buffer.from("Look up current weather", "utf8")));
|
|
assert.ok(body.includes(Buffer.from("schema-bytes")));
|
|
assert.ok(body.includes(Buffer.from("omniroute", "utf8")));
|
|
});
|
|
|
|
test("encodeMcpToolDefinitionBody omits optional providerIdentifier and toolName", () => {
|
|
const body = encodeMcpToolDefinitionBody({
|
|
name: "n",
|
|
description: "d",
|
|
inputSchemaBytes: Buffer.alloc(0),
|
|
});
|
|
assert.ok(!body.includes(Buffer.from("omniroute", "utf8")));
|
|
});
|
|
|
|
// ─── Phase 1: encodeRequestContextResponse with tools ───────────────────────
|
|
|
|
test("encodeRequestContextResponse with no tools produces empty RequestContext (existing behavior)", () => {
|
|
const framed = encodeRequestContextResponse(5, "exec-rc");
|
|
assertEcmShape(framed, 5, "exec-rc", 10); // request_context_result = field 10
|
|
});
|
|
|
|
test("encodeRequestContextResponse with tools embeds tool name and schema", () => {
|
|
const framed = encodeRequestContextResponse(5, "exec-rc", [
|
|
{
|
|
name: "get_weather",
|
|
description: "lookup",
|
|
inputSchemaBytes: jsonSchemaToProtobufValue({
|
|
type: "object",
|
|
properties: { city: { type: "string" } },
|
|
}),
|
|
providerIdentifier: "omniroute",
|
|
toolName: "get_weather",
|
|
},
|
|
]);
|
|
assertEcmShape(framed, 5, "exec-rc", 10);
|
|
const payload = unwrapFrame(framed);
|
|
assert.ok(payload.includes(Buffer.from("get_weather", "utf8")));
|
|
assert.ok(payload.includes(Buffer.from("city", "utf8")));
|
|
});
|
|
|
|
// ─── Phase 1: jsonSchemaToProtobufValue ─────────────────────────────────────
|
|
//
|
|
// google.protobuf.Value is a oneof message. We verify the encoded bytes
|
|
// start with the right field tag for each kind, and (for nested types) that
|
|
// the inner content survives.
|
|
|
|
test("jsonSchemaToProtobufValue encodes a string as field 3", () => {
|
|
const buf = jsonSchemaToProtobufValue("hello");
|
|
// tag (3<<3)|2 = 26
|
|
assert.equal(buf[0], 26);
|
|
assert.ok(buf.includes(Buffer.from("hello", "utf8")));
|
|
});
|
|
|
|
test("jsonSchemaToProtobufValue encodes a number as field 2 (double)", () => {
|
|
const buf = jsonSchemaToProtobufValue(3.14);
|
|
// tag (2<<3)|1 = 17
|
|
assert.equal(buf[0], 17);
|
|
assert.equal(buf.length, 9); // 1-byte tag + 8-byte double
|
|
assert.equal(buf.readDoubleLE(1), 3.14);
|
|
});
|
|
|
|
test("jsonSchemaToProtobufValue encodes a bool as field 4", () => {
|
|
const buf = jsonSchemaToProtobufValue(true);
|
|
// tag (4<<3)|0 = 32
|
|
assert.equal(buf[0], 32);
|
|
assert.equal(buf[1], 1);
|
|
});
|
|
|
|
test("jsonSchemaToProtobufValue encodes null as field 1", () => {
|
|
const buf = jsonSchemaToProtobufValue(null);
|
|
// tag (1<<3)|0 = 8
|
|
assert.equal(buf[0], 8);
|
|
assert.equal(buf[1], 0);
|
|
});
|
|
|
|
test("jsonSchemaToProtobufValue encodes a list as field 6", () => {
|
|
const buf = jsonSchemaToProtobufValue(["a", "b"]);
|
|
// tag (6<<3)|2 = 50
|
|
assert.equal(buf[0], 50);
|
|
assert.ok(buf.includes(Buffer.from("a", "utf8")));
|
|
assert.ok(buf.includes(Buffer.from("b", "utf8")));
|
|
});
|
|
|
|
test("jsonSchemaToProtobufValue encodes a struct as field 5", () => {
|
|
const buf = jsonSchemaToProtobufValue({ key: "value" });
|
|
// tag (5<<3)|2 = 42
|
|
assert.equal(buf[0], 42);
|
|
assert.ok(buf.includes(Buffer.from("key", "utf8")));
|
|
assert.ok(buf.includes(Buffer.from("value", "utf8")));
|
|
});
|
|
|
|
test("jsonSchemaToProtobufValue encodes a nested OpenAI tool input_schema", () => {
|
|
const schema = {
|
|
type: "object",
|
|
properties: {
|
|
city: { type: "string", description: "the city" },
|
|
units: { type: "string", enum: ["c", "f"] },
|
|
},
|
|
required: ["city"],
|
|
};
|
|
const buf = jsonSchemaToProtobufValue(schema);
|
|
// Outer tag is struct (5)
|
|
assert.equal(buf[0], 42);
|
|
assert.ok(buf.includes(Buffer.from("city", "utf8")));
|
|
assert.ok(buf.includes(Buffer.from("units", "utf8")));
|
|
assert.ok(buf.includes(Buffer.from("required", "utf8")));
|
|
assert.ok(buf.includes(Buffer.from("the city", "utf8")));
|
|
});
|
|
|
|
// ─── Phase 3: tools in AgentRunRequest ─────────────────────────────────────
|
|
|
|
test("openAIToolsToMcpDefs converts OpenAI tool array to McpToolDefinition[]", () => {
|
|
const defs = openAIToolsToMcpDefs([
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "lookup",
|
|
parameters: { type: "object", properties: { city: { type: "string" } } },
|
|
},
|
|
},
|
|
]);
|
|
assert.equal(defs.length, 1);
|
|
assert.equal(defs[0].name, "get_weather");
|
|
assert.equal(defs[0].description, "lookup");
|
|
assert.equal(defs[0].toolName, "get_weather");
|
|
assert.equal(defs[0].providerIdentifier, "omniroute");
|
|
assert.ok(defs[0].inputSchemaBytes.length > 0);
|
|
// Schema bytes are a Struct (field 5)
|
|
assert.equal(defs[0].inputSchemaBytes[0], 42);
|
|
});
|
|
|
|
test("openAIToolsToMcpDefs supplies a default schema if parameters omitted", () => {
|
|
const defs = openAIToolsToMcpDefs([{ type: "function", function: { name: "no_params" } }]);
|
|
assert.equal(defs.length, 1);
|
|
// Default schema is { type: "object", properties: {} } — encoded as struct
|
|
assert.equal(defs[0].inputSchemaBytes[0], 42);
|
|
});
|
|
|
|
test("encodeAgentRunRequest with tools embeds tool name and schema in mcp_tools", () => {
|
|
const buf = encodeAgentRunRequest({
|
|
modelId: "claude-4.6-sonnet-medium",
|
|
userText: "what's the weather?",
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
function: {
|
|
name: "get_weather",
|
|
description: "Look up current weather",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { city: { type: "string" } },
|
|
required: ["city"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const text = buf.toString("latin1");
|
|
assert.ok(text.includes("get_weather"), "tool name present");
|
|
assert.ok(text.includes("Look up current weather"), "tool description present");
|
|
assert.ok(text.includes("city"), "tool schema field present");
|
|
assert.ok(text.includes("omniroute"), "provider_identifier set");
|
|
});
|
|
|
|
test("encodeAgentRunRequest without tools preserves empty mcp_tools placeholder", () => {
|
|
const bufNoTools = encodeAgentRunRequest({
|
|
modelId: "auto",
|
|
userText: "hi",
|
|
});
|
|
const bufEmptyTools = encodeAgentRunRequest({
|
|
modelId: "auto",
|
|
userText: "hi",
|
|
tools: [],
|
|
});
|
|
// Both should produce essentially the same shape — neither embeds tools.
|
|
// Lengths may differ by message-id randomness; just verify neither contains
|
|
// tool-related markers.
|
|
assert.ok(!bufNoTools.toString("latin1").includes("omniroute"));
|
|
assert.ok(!bufEmptyTools.toString("latin1").includes("omniroute"));
|
|
});
|
|
|
|
test("encodeAgentRunRequest with multiple tools embeds all of them", () => {
|
|
const buf = encodeAgentRunRequest({
|
|
modelId: "auto",
|
|
userText: "test",
|
|
tools: [
|
|
{ type: "function", function: { name: "tool_a", description: "A" } },
|
|
{ type: "function", function: { name: "tool_b", description: "B" } },
|
|
],
|
|
});
|
|
const text = buf.toString("latin1");
|
|
assert.ok(text.includes("tool_a"));
|
|
assert.ok(text.includes("tool_b"));
|
|
});
|