mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
merge: sync master into dev
Sync master into dev while preserving dev-specific feature logic and include the required co-author trailer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,24 +1,35 @@
|
||||
// #2591 — Alibaba Intl (alicode-intl) must use the OpenAI-compatible-mode
|
||||
// DashScope endpoint so standard DashScope API keys work. The previous
|
||||
// coding-intl host only accepted Alibaba Coding Plan keys and rejected
|
||||
// ordinary DashScope keys with "Invalid API key".
|
||||
// #2591 — Alibaba Intl key types split across two hosts:
|
||||
// - alicode-intl: Coding Plan keys (sk-sp-...) → coding-intl.dashscope.aliyuncs.com
|
||||
// - alims-intl: standard DashScope API keys (sk-...) → dashscope-intl.aliyuncs.com/compatible-mode
|
||||
// The two key types are NOT interchangeable across hosts. Split into two providers
|
||||
// so each key type reaches its own host.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import alicodeIntl from "../../open-sse/providers/registry/alicode-intl.js";
|
||||
import alimsIntl from "../../open-sse/providers/registry/alims-intl.js";
|
||||
|
||||
describe("alicode-intl endpoint (issue #2591)", () => {
|
||||
it("routes to the compatible-mode DashScope endpoint", () => {
|
||||
describe("alicode-intl endpoint (Coding Plan keys)", () => {
|
||||
it("routes to the coding-intl host for Coding Plan keys", () => {
|
||||
expect(alicodeIntl.id).toBe("alicode-intl");
|
||||
expect(alicodeIntl.transport.baseUrl).toBe(
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
"https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not use the coding-intl host that rejects standard keys", () => {
|
||||
expect(alicodeIntl.transport.baseUrl).not.toContain("coding-intl.dashscope.aliyuncs.com");
|
||||
});
|
||||
|
||||
it("keeps the chat/completions path and preserveCacheControl quirk", () => {
|
||||
expect(alicodeIntl.transport.baseUrl).toContain("/v1/chat/completions");
|
||||
expect(alicodeIntl.transport.quirks.preserveCacheControl).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("alims-intl endpoint (standard DashScope keys)", () => {
|
||||
it("routes to the compatible-mode DashScope endpoint for standard keys", () => {
|
||||
expect(alimsIntl.id).toBe("alims-intl");
|
||||
expect(alimsIntl.transport.baseUrl).toBe(
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not use the coding-intl host that rejects standard keys", () => {
|
||||
expect(alimsIntl.transport.baseUrl).not.toContain("coding-intl.dashscope.aliyuncs.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,7 +152,8 @@ describe("Codex Refresh Token", () => {
|
||||
expect(getRefreshLeadMs("claude")).toBe(4 * 60 * 60 * 1000); // 4 hours
|
||||
expect(getRefreshLeadMs("iflow")).toBe(24 * 60 * 60 * 1000); // 24 hours
|
||||
expect(getRefreshLeadMs("qwen")).toBe(20 * 60 * 1000); // 20 minutes
|
||||
expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // 5 minutes
|
||||
expect(getRefreshLeadMs("kimi")).toBe(5 * 60 * 1000); // 5 minutes
|
||||
expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // legacy alias
|
||||
expect(getRefreshLeadMs("antigravity")).toBe(5 * 60 * 1000); // 5 minutes
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decodeMessage,
|
||||
encodeField,
|
||||
encodeAgentValue,
|
||||
decodeAgentValue,
|
||||
encodeMcpToolDefinition,
|
||||
encodeMcpTools,
|
||||
decodeMcpArgs,
|
||||
encodeMcpResultSuccess,
|
||||
encodeMcpResultError,
|
||||
encodeMcpResultToolNotFound,
|
||||
} from "../../open-sse/utils/cursorProtobuf.js";
|
||||
import {
|
||||
isAgentCapableRequest,
|
||||
buildAgentRunFrame,
|
||||
} from "../../open-sse/executors/cursor.js";
|
||||
|
||||
// AgentService (agent.v1) codec tests — validate the production implementation
|
||||
// in cursorProtobuf.js + the executor's frame builders. Pure round-trip, no network.
|
||||
// Field numbers verified against Cursor's agent.proto (extracted via @oh-my-pi).
|
||||
|
||||
const LEN = 2;
|
||||
// McpArgs.args map entry { field1: key, field2: Value }
|
||||
const entry = (k, v) => Buffer.concat([
|
||||
Buffer.from(encodeField(2, LEN,
|
||||
Buffer.concat([Buffer.from(encodeField(1, LEN, k)), Buffer.from(encodeField(2, LEN, encodeAgentValue(v)))])
|
||||
)),
|
||||
]);
|
||||
|
||||
describe("Cursor AgentService codec (cursorProtobuf.js)", () => {
|
||||
describe("google.protobuf.Value round-trip", () => {
|
||||
const cases = [
|
||||
["null", null],
|
||||
["bool true", true],
|
||||
["bool false", false],
|
||||
["string", "hello"],
|
||||
["integer", 42],
|
||||
["float", 3.14],
|
||||
["empty object", {}],
|
||||
["flat object", { a: 1, b: "x", c: true }],
|
||||
["nested object", { outer: { inner: [1, 2, "three"] } }],
|
||||
["array of mixed", [1, "two", false, null]],
|
||||
["deeply nested", { a: { b: { c: { d: 1 } } } }],
|
||||
];
|
||||
for (const [label, value] of cases) {
|
||||
it(`encodes/decodes ${label}`, () => {
|
||||
expect(decodeAgentValue(encodeAgentValue(value))).toEqual(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("McpToolDefinition", () => {
|
||||
it("encodes name, description, input_schema (Value), provider, tool_name", () => {
|
||||
const schema = { type: "object", properties: { city: { type: "string" } }, required: ["city"] };
|
||||
const def = encodeMcpToolDefinition({ function: { name: "get_weather", description: "Get weather", parameters: schema } });
|
||||
const msg = decodeMessage(def);
|
||||
expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("get_weather");
|
||||
expect(Buffer.from(msg.get(2)[0].value).toString("utf8")).toBe("Get weather");
|
||||
expect(Buffer.from(msg.get(4)[0].value).toString("utf8")).toBe("9router");
|
||||
expect(Buffer.from(msg.get(5)[0].value).toString("utf8")).toBe("get_weather");
|
||||
expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema);
|
||||
});
|
||||
|
||||
it("preserves nested JSON-schema types", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "search query" },
|
||||
opts: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["query"],
|
||||
};
|
||||
const def = encodeMcpToolDefinition({ function: { name: "search", parameters: schema } });
|
||||
const msg = decodeMessage(def);
|
||||
expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema);
|
||||
});
|
||||
|
||||
it("accepts flat tool shape (no .function wrapper)", () => {
|
||||
const def = encodeMcpToolDefinition({ name: "noop", description: "d", inputSchema: { type: "object" } });
|
||||
const msg = decodeMessage(def);
|
||||
expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("noop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeMcpTools", () => {
|
||||
it("produces empty bytes for no tools", () => {
|
||||
expect(encodeMcpTools([]).length).toBe(0);
|
||||
expect(encodeMcpTools().length).toBe(0);
|
||||
});
|
||||
|
||||
it("wraps multiple tool defs as repeated field 1", () => {
|
||||
const tools = [
|
||||
{ function: { name: "get_weather", parameters: { type: "object" } } },
|
||||
{ function: { name: "calculate", parameters: { type: "object" } } },
|
||||
];
|
||||
const mcpTools = encodeMcpTools(tools);
|
||||
const inner = decodeMessage(mcpTools);
|
||||
expect(inner.get(1).length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpArgs decode", () => {
|
||||
it("decodes name, toolName, toolCallId, and typed args map", () => {
|
||||
const argsBytes = Buffer.concat([
|
||||
entry("city", "Hanoi"),
|
||||
entry("count", 5),
|
||||
entry("flag", true),
|
||||
entry("nested", { a: [1, 2] }),
|
||||
]);
|
||||
const mcpArgs = Buffer.concat([
|
||||
Buffer.from(encodeField(1, LEN, "get_weather")),
|
||||
argsBytes,
|
||||
Buffer.from(encodeField(3, LEN, "call_abc")),
|
||||
Buffer.from(encodeField(5, LEN, "get_weather")),
|
||||
]);
|
||||
const decoded = decodeMcpArgs(mcpArgs);
|
||||
expect(decoded.name).toBe("get_weather");
|
||||
expect(decoded.toolName).toBe("get_weather");
|
||||
expect(decoded.toolCallId).toBe("call_abc");
|
||||
expect(decoded.args).toEqual({ city: "Hanoi", count: 5, flag: true, nested: { a: [1, 2] } });
|
||||
});
|
||||
|
||||
it("handles empty args map", () => {
|
||||
const mcpArgs = Buffer.concat([
|
||||
Buffer.from(encodeField(1, LEN, "noop")),
|
||||
Buffer.from(encodeField(5, LEN, "noop")),
|
||||
]);
|
||||
expect(decodeMcpArgs(mcpArgs).args).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResult success", () => {
|
||||
it("builds success with single text content", () => {
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ['{"temp":32}'], isError: false });
|
||||
const msg = decodeMessage(bytes); // McpResult level
|
||||
expect(msg.has(1)).toBe(true); // success variant
|
||||
const success = decodeMessage(msg.get(1)[0].value);
|
||||
expect(success.get(1).length).toBe(1);
|
||||
expect(success.get(2)[0].value).toBe(0); // is_error=false
|
||||
const item = decodeMessage(success.get(1)[0].value);
|
||||
const textContent = decodeMessage(item.get(1)[0].value);
|
||||
expect(Buffer.from(textContent.get(1)[0].value).toString("utf8")).toBe('{"temp":32}');
|
||||
});
|
||||
|
||||
it("builds success with multiple text items", () => {
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ["line1", "line2"] });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
expect(success.get(1).length).toBe(2);
|
||||
});
|
||||
|
||||
it("marks is_error=true", () => {
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ["fail"], isError: true });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
expect(success.get(2)[0].value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResult image content", () => {
|
||||
it("builds image item with raw bytes + mime type", () => {
|
||||
const imgBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const bytes = encodeMcpResultSuccess({ imageItems: [{ data: imgBytes, mimeType: "image/png" }] });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
const item = decodeMessage(success.get(1)[0].value);
|
||||
expect(item.has(2)).toBe(true); // image variant
|
||||
const img = decodeMessage(item.get(2)[0].value);
|
||||
expect(Buffer.from(img.get(1)[0].value)).toEqual(Buffer.from(imgBytes));
|
||||
expect(Buffer.from(img.get(2)[0].value).toString("utf8")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("builds mixed text + image content", () => {
|
||||
const imgBytes = new Uint8Array([1, 2, 3]);
|
||||
const bytes = encodeMcpResultSuccess({ textItems: ["see image"], imageItems: [{ data: imgBytes, mimeType: "image/jpeg" }] });
|
||||
const success = decodeMessage(decodeMessage(bytes).get(1)[0].value);
|
||||
expect(success.get(1).length).toBe(2);
|
||||
expect(decodeMessage(success.get(1)[0].value).has(1)).toBe(true); // text
|
||||
expect(decodeMessage(success.get(1)[1].value).has(2)).toBe(true); // image
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResult error / toolNotFound", () => {
|
||||
it("builds error result (field 2)", () => {
|
||||
const bytes = encodeMcpResultError("tool crashed");
|
||||
const msg = decodeMessage(bytes);
|
||||
expect(msg.has(2)).toBe(true);
|
||||
const err = decodeMessage(msg.get(2)[0].value);
|
||||
expect(Buffer.from(err.get(1)[0].value).toString("utf8")).toBe("tool crashed");
|
||||
});
|
||||
|
||||
it("builds toolNotFound result (field 5)", () => {
|
||||
const bytes = encodeMcpResultToolNotFound("missing_tool");
|
||||
const msg = decodeMessage(bytes);
|
||||
expect(msg.has(5)).toBe(true);
|
||||
const tnf = decodeMessage(msg.get(5)[0].value);
|
||||
expect(Buffer.from(tnf.get(1)[0].value).toString("utf8")).toBe("missing_tool");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cursor AgentService executor helpers (cursor.js)", () => {
|
||||
describe("isAgentCapableRequest", () => {
|
||||
it("accepts plain text content", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts array text content", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts request with tools declared", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }], tools: [{ function: { name: "t" } }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts history with assistant tool_calls + tool results", () => {
|
||||
expect(isAgentCapableRequest({
|
||||
messages: [
|
||||
{ role: "user", content: "weather?" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: "{}" } }] },
|
||||
{ role: "tool", tool_call_id: "c1", content: "sunny" },
|
||||
{ role: "user", content: "thanks" },
|
||||
],
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-text (image) content", () => {
|
||||
expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "image_url" }] }] })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing messages", () => {
|
||||
expect(isAgentCapableRequest({})).toBe(false);
|
||||
expect(isAgentCapableRequest(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentRunFrame", () => {
|
||||
// buildAgentRunFrame returns a wrapped Connect-RPC frame (5-byte header + AgentClientMessage).
|
||||
const unwrap = (frame) => frame.subarray(5);
|
||||
|
||||
it("encodes a text-only run request with system + model", () => {
|
||||
const frame = unwrap(buildAgentRunFrame(
|
||||
[{ role: "system", content: "be brief" }, { role: "user", content: "hi" }],
|
||||
"gpt-5.2",
|
||||
));
|
||||
const clientMsg = decodeMessage(frame);
|
||||
expect(clientMsg.has(1)).toBe(true); // run_request
|
||||
const run = decodeMessage(clientMsg.get(1)[0].value);
|
||||
expect(run.has(2)).toBe(true); // action
|
||||
expect(run.has(9)).toBe(true); // requested_model
|
||||
});
|
||||
|
||||
it("encodes mcp_tools (field 4) when tools are provided", () => {
|
||||
const tools = [{ function: { name: "get_weather", description: "weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }];
|
||||
const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "weather?" }], "gpt-5.2", tools));
|
||||
const run = decodeMessage(decodeMessage(frame).get(1)[0].value);
|
||||
expect(run.has(4)).toBe(true); // mcp_tools
|
||||
const mcpTools = decodeMessage(run.get(4)[0].value);
|
||||
expect(mcpTools.get(1).length).toBe(1);
|
||||
});
|
||||
|
||||
it("omits mcp_tools when no tools provided", () => {
|
||||
const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "hi" }], "gpt-5.2", []));
|
||||
const run = decodeMessage(decodeMessage(frame).get(1)[0].value);
|
||||
expect(run.has(4)).toBe(false);
|
||||
});
|
||||
|
||||
it("encodes conversation_history from prior turns including tool calls/results", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "weather in Tokyo?" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }] },
|
||||
{ role: "tool", tool_call_id: "c1", content: "18C cloudy" },
|
||||
{ role: "user", content: "thanks" },
|
||||
];
|
||||
const frame = unwrap(buildAgentRunFrame(messages, "gpt-5.2", []));
|
||||
const run = decodeMessage(decodeMessage(frame).get(1)[0].value);
|
||||
const action = decodeMessage(run.get(2)[0].value);
|
||||
const userAction = decodeMessage(action.get(1)[0].value);
|
||||
expect(userAction.has(7)).toBe(true); // conversation_history (field 7)
|
||||
const history = decodeMessage(userAction.get(7)[0].value);
|
||||
expect(history.get(1).length).toBeGreaterThanOrEqual(2); // prior turns
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearCursorModelCache,
|
||||
parseCursorUsableModels,
|
||||
resolveCursorModels,
|
||||
} from "../../open-sse/services/cursorModels.js";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
function varint(value) {
|
||||
const bytes = [];
|
||||
while (value >= 0x80) {
|
||||
bytes.push((value & 0x7f) | 0x80);
|
||||
value >>>= 7;
|
||||
}
|
||||
bytes.push(value);
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
function field(fieldNumber, value) {
|
||||
return Uint8Array.from([(fieldNumber << 3) | 2, ...varint(value.length), ...value]);
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function concat(...parts) {
|
||||
const size = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const result = new Uint8Array(size);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function model(id, name) {
|
||||
return field(1, concat(field(1, text(id)), field(4, text(name))));
|
||||
}
|
||||
|
||||
describe("Cursor live model catalog", () => {
|
||||
beforeEach(() => {
|
||||
clearCursorModelCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
clearCursorModelCache();
|
||||
});
|
||||
|
||||
it("decodes the GetUsableModels protobuf response", () => {
|
||||
const payload = concat(
|
||||
model("default", "Auto"),
|
||||
model("gpt-5.3-codex", "GPT 5.3 Codex"),
|
||||
model("gpt-5.3-codex", "Duplicate"),
|
||||
);
|
||||
|
||||
expect(parseCursorUsableModels(payload)).toEqual([
|
||||
{ id: "default", name: "Auto" },
|
||||
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetches the account-specific catalog and caches it", async () => {
|
||||
const payload = concat(model("claude-4.6-opus", "Claude 4.6 Opus"));
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response(payload, { status: 200 }));
|
||||
const credentials = {
|
||||
accessToken: "cursor-token",
|
||||
providerSpecificData: { machineId: "machine-id" },
|
||||
};
|
||||
|
||||
await expect(resolveCursorModels(credentials)).resolves.toEqual({
|
||||
models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }],
|
||||
});
|
||||
await expect(resolveCursorModels(credentials)).resolves.toEqual({
|
||||
models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }],
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://agent.api5.cursor.sh/agent.v1.AgentService/GetUsableModels",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(Uint8Array),
|
||||
headers: expect.objectContaining({
|
||||
"content-type": "application/proto",
|
||||
accept: "application/proto",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails open when the Cursor catalog request fails", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response("no", { status: 403 }));
|
||||
|
||||
await expect(resolveCursorModels({
|
||||
accessToken: "cursor-token",
|
||||
providerSpecificData: { machineId: "machine-id" },
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyGrokBuildConfig,
|
||||
getGrokSubagentSlot,
|
||||
parseGrokBuildConfig,
|
||||
resetGrokBuildConfig,
|
||||
} from "../../src/lib/grokBuildConfig.js";
|
||||
|
||||
const BASE_CONFIG = `[cli]
|
||||
installer = "internal"
|
||||
|
||||
[ui]
|
||||
yolo = false
|
||||
|
||||
[models]
|
||||
default = "grok-4.5"
|
||||
default_reasoning_effort = "high"
|
||||
|
||||
[subagents]
|
||||
enabled = true
|
||||
|
||||
[subagents.models]
|
||||
general-purpose = "grok-4.5"
|
||||
explore = "grok-build"
|
||||
plan = "grok-4.5"
|
||||
|
||||
[mcp_servers.example]
|
||||
url = "https://example.com/mcp"
|
||||
enabled = true
|
||||
`;
|
||||
|
||||
const APPLY_INPUT = {
|
||||
baseUrl: "http://127.0.0.1:20128/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "cx/gpt-5.6-sol",
|
||||
contextWindow: 400000,
|
||||
subagentModels: {
|
||||
"general-purpose": { model: "cc/claude-sonnet-5", contextWindow: 1000000 },
|
||||
explore: { model: "gemini/gemini-3-flash", contextWindow: 1048576 },
|
||||
},
|
||||
};
|
||||
|
||||
describe("grokBuildConfig", () => {
|
||||
it("creates independent main and per-type subagent model slots", () => {
|
||||
const result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
const parsed = parseGrokBuildConfig(result);
|
||||
|
||||
expect(parsed.default).toBe("9router");
|
||||
expect(parsed.model).toMatchObject({
|
||||
model: "cx/gpt-5.6-sol",
|
||||
base_url: "http://127.0.0.1:20128/v1",
|
||||
context_window: 400000,
|
||||
});
|
||||
expect(parsed.subagentMappings).toMatchObject({
|
||||
"general-purpose": "9router-general-purpose",
|
||||
explore: "9router-explore",
|
||||
plan: "grok-4.5",
|
||||
});
|
||||
expect(parsed.subagentModels["general-purpose"]).toMatchObject({
|
||||
model: "cc/claude-sonnet-5",
|
||||
context_window: 1000000,
|
||||
});
|
||||
expect(parsed.subagentModels.explore).toMatchObject({
|
||||
model: "gemini/gemini-3-flash",
|
||||
context_window: 1048576,
|
||||
});
|
||||
expect(parsed.subagentModels.plan).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves unrelated config sections", () => {
|
||||
const result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
expect(result).toContain("[cli]\ninstaller = \"internal\"");
|
||||
expect(result).toContain("[ui]\nyolo = false");
|
||||
expect(result).toContain("default_reasoning_effort = \"high\"");
|
||||
expect(result).toContain("[mcp_servers.example]");
|
||||
expect(result).toContain("url = \"https://example.com/mcp\"");
|
||||
});
|
||||
|
||||
it("is idempotent and updates owned slots without duplicate sections", () => {
|
||||
let result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
result = applyGrokBuildConfig(result, {
|
||||
...APPLY_INPUT,
|
||||
model: "cc/claude-opus-4.8",
|
||||
contextWindow: 1000000,
|
||||
subagentModels: {
|
||||
...APPLY_INPUT.subagentModels,
|
||||
explore: { model: "mimo/mimo", contextWindow: 262144 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.match(/^\[model\.9router\]$/gm)).toHaveLength(1);
|
||||
expect(result.match(/^\[model\.9router-general-purpose\]$/gm)).toHaveLength(1);
|
||||
expect(result.match(/^\[model\.9router-explore\]$/gm)).toHaveLength(1);
|
||||
expect(result.match(/^# 9router-prev-subagent-explore/gm)).toHaveLength(1);
|
||||
expect(parseGrokBuildConfig(result).model).toMatchObject({
|
||||
model: "cc/claude-opus-4.8",
|
||||
context_window: 1000000,
|
||||
});
|
||||
expect(parseGrokBuildConfig(result).subagentModels.explore).toMatchObject({
|
||||
model: "mimo/mimo",
|
||||
context_window: 262144,
|
||||
});
|
||||
});
|
||||
|
||||
it("blank override restores previous subagent mapping and removes owned slot", () => {
|
||||
let result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
result = applyGrokBuildConfig(result, {
|
||||
...APPLY_INPUT,
|
||||
subagentModels: {
|
||||
"general-purpose": APPLY_INPUT.subagentModels["general-purpose"],
|
||||
// explore omitted => inherit / restore previous
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = parseGrokBuildConfig(result);
|
||||
expect(parsed.subagentMappings.explore).toBe("grok-build");
|
||||
expect(parsed.subagentModels.explore).toBeNull();
|
||||
expect(result).not.toContain("[model.9router-explore]");
|
||||
expect(parsed.subagentMappings["general-purpose"]).toBe("9router-general-purpose");
|
||||
});
|
||||
|
||||
it("reset restores previous default and all previous subagent mappings", () => {
|
||||
const applied = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
const reset = resetGrokBuildConfig(applied);
|
||||
const parsed = parseGrokBuildConfig(reset);
|
||||
|
||||
expect(parsed.default).toBe("grok-4.5");
|
||||
expect(parsed.model).toBeNull();
|
||||
expect(parsed.subagentMappings).toEqual({
|
||||
"general-purpose": "grok-4.5",
|
||||
explore: "grok-build",
|
||||
plan: "grok-4.5",
|
||||
});
|
||||
expect(reset).not.toContain("[model.9router-");
|
||||
expect(reset).not.toContain("9router-prev-");
|
||||
expect(reset).toContain("[mcp_servers.example]");
|
||||
});
|
||||
|
||||
it("removes mappings that were originally unset", () => {
|
||||
const config = `[models]\ndefault = "grok-build"\n\n[mcp_servers.x]\nenabled = true\n`;
|
||||
const applied = applyGrokBuildConfig(config, {
|
||||
...APPLY_INPUT,
|
||||
subagentModels: {
|
||||
plan: { model: "cc/claude-sonnet-5", contextWindow: 1000000 },
|
||||
},
|
||||
});
|
||||
const reset = resetGrokBuildConfig(applied);
|
||||
|
||||
expect(parseGrokBuildConfig(applied).subagentMappings.plan).toBe("9router-plan");
|
||||
expect(parseGrokBuildConfig(reset).subagentMappings.plan).toBeNull();
|
||||
expect(reset).not.toContain("[subagents.models]");
|
||||
expect(reset).toContain("[mcp_servers.x]");
|
||||
});
|
||||
|
||||
it("legacy callers without subagentModels leave existing overrides untouched", () => {
|
||||
const applied = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT);
|
||||
const updatedMainOnly = applyGrokBuildConfig(applied, {
|
||||
baseUrl: APPLY_INPUT.baseUrl,
|
||||
apiKey: APPLY_INPUT.apiKey,
|
||||
model: "gemini/gemini-3.1-pro",
|
||||
contextWindow: 1048576,
|
||||
});
|
||||
|
||||
const parsed = parseGrokBuildConfig(updatedMainOnly);
|
||||
expect(parsed.model.model).toBe("gemini/gemini-3.1-pro");
|
||||
expect(parsed.subagentMappings.explore).toBe("9router-explore");
|
||||
expect(parsed.subagentModels.explore.model).toBe("gemini/gemini-3-flash");
|
||||
});
|
||||
|
||||
it("returns stable slot names only for supported subagent types", () => {
|
||||
expect(getGrokSubagentSlot("general-purpose")).toBe("9router-general-purpose");
|
||||
expect(getGrokSubagentSlot("explore")).toBe("9router-explore");
|
||||
expect(getGrokSubagentSlot("plan")).toBe("9router-plan");
|
||||
expect(getGrokSubagentSlot("unknown")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/usageDb.js", () => ({
|
||||
appendRequestLog: vi.fn(async () => {}),
|
||||
saveRequestDetail: vi.fn(async () => {}),
|
||||
saveRequestUsage: vi.fn(async () => {})
|
||||
}));
|
||||
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.js");
|
||||
const {
|
||||
handleForcedSSEToJson,
|
||||
parseSSEToOpenAIResponse
|
||||
} = await import("../../open-sse/handlers/chatCore/sseToJsonHandler.js");
|
||||
|
||||
describe("Kiro non-streaming error propagation", () => {
|
||||
it("prefers a terminal SSE error over earlier semantic chunks", () => {
|
||||
const raw = [
|
||||
'data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}',
|
||||
'data: {"error":{"message":"Kiro transport failed","code":"kiro_missing_terminal"}}',
|
||||
"data: [DONE]"
|
||||
].join("\n\n");
|
||||
|
||||
expect(parseSSEToOpenAIResponse(raw, "kiro")).toEqual({
|
||||
error: {
|
||||
message: "Kiro transport failed",
|
||||
code: "kiro_missing_terminal"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 502 instead of collapsing a failed Kiro SSE stream into stop", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const raw = [
|
||||
'data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}',
|
||||
'data: {"error":{"message":"Kiro stream ended incompletely","code":"kiro_missing_terminal"}}',
|
||||
"data: [DONE]",
|
||||
""
|
||||
].join("\n\n");
|
||||
const result = await handleForcedSSEToJson({
|
||||
providerResponse: new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(raw));
|
||||
controller.close();
|
||||
}
|
||||
}), { headers: { "content-type": "text/event-stream" } }),
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
provider: "kiro",
|
||||
model: "kr/claude-opus-4.8",
|
||||
body: { model: "kr/claude-opus-4.8", messages: [] },
|
||||
stream: false,
|
||||
requestStartTime: Date.now(),
|
||||
connectionId: "test-connection",
|
||||
clientRawRequest: { endpoint: "/v1/chat/completions" },
|
||||
trackDone: vi.fn(),
|
||||
appendLog: vi.fn()
|
||||
});
|
||||
const json = await result.response.json();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.response.status).toBe(502);
|
||||
expect(json.error.message).toContain("Kiro stream ended incompletely");
|
||||
expect(json).not.toHaveProperty("choices");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,778 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: (...args) => fetchMock(...args)
|
||||
}));
|
||||
|
||||
const { KiroExecutor } = await import("../../open-sse/executors/kiro.js");
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const credentials = {
|
||||
accessToken: "test-token",
|
||||
providerSpecificData: { kiroToolCallRepair: true }
|
||||
};
|
||||
|
||||
function crc32(bytes) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function encodeHeader(name, value) {
|
||||
const nameBytes = encoder.encode(name);
|
||||
const valueBytes = encoder.encode(value);
|
||||
const bytes = new Uint8Array(1 + nameBytes.length + 3 + valueBytes.length);
|
||||
let offset = 0;
|
||||
bytes[offset++] = nameBytes.length;
|
||||
bytes.set(nameBytes, offset);
|
||||
offset += nameBytes.length;
|
||||
bytes[offset++] = 7;
|
||||
new DataView(bytes.buffer).setUint16(offset, valueBytes.length, false);
|
||||
offset += 2;
|
||||
bytes.set(valueBytes, offset);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concat(chunks) {
|
||||
const output = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0));
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function frameFromEntries(entries, payload) {
|
||||
const headers = concat(entries.map(([name, value]) => encodeHeader(name, value)));
|
||||
const payloadBytes = encoder.encode(JSON.stringify(payload));
|
||||
const totalLength = 12 + headers.byteLength + payloadBytes.byteLength + 4;
|
||||
const frame = new Uint8Array(totalLength);
|
||||
const view = new DataView(frame.buffer);
|
||||
view.setUint32(0, totalLength, false);
|
||||
view.setUint32(4, headers.byteLength, false);
|
||||
frame.set(headers, 12);
|
||||
frame.set(payloadBytes, 12 + headers.byteLength);
|
||||
return checksum(frame);
|
||||
}
|
||||
|
||||
function frame(eventType, payload) {
|
||||
return frameFromEntries([[":event-type", eventType]], payload);
|
||||
}
|
||||
|
||||
function checksum(bytes) {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
view.setUint32(8, crc32(bytes.subarray(0, 8)), false);
|
||||
view.setUint32(bytes.byteLength - 4, crc32(bytes.subarray(0, bytes.byteLength - 4)), false);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function response(frames, status = 200) {
|
||||
return new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
for (const value of frames) controller.enqueue(value);
|
||||
controller.close();
|
||||
}
|
||||
}), { status, statusText: status === 200 ? "OK" : "Upstream Error" });
|
||||
}
|
||||
|
||||
function controlledResponse(frames = []) {
|
||||
let controller;
|
||||
const value = new Response(new ReadableStream({
|
||||
start(streamController) {
|
||||
controller = streamController;
|
||||
for (const item of frames) controller.enqueue(item);
|
||||
}
|
||||
}), { status: 200 });
|
||||
return {
|
||||
value,
|
||||
enqueue(item) {
|
||||
controller.enqueue(item);
|
||||
},
|
||||
close() {
|
||||
controller.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function text(stream) {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let output = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return output + decoder.decode();
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(executor = new KiroExecutor(), overrides = {}) {
|
||||
return executor.execute({
|
||||
model: "kr/claude-opus-4.8",
|
||||
body: { systemPrompt: "base", conversationState: {} },
|
||||
stream: true,
|
||||
credentials,
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES;
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS;
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES;
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS;
|
||||
delete process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
describe("Kiro terminal integrity recovery", () => {
|
||||
it("keeps semantic output private behind a heartbeat until clean EOF", async () => {
|
||||
const upstream = controlledResponse([
|
||||
frame("assistantResponseEvent", { content: "private until validated" })
|
||||
]);
|
||||
fetchMock.mockResolvedValueOnce(upstream.value);
|
||||
|
||||
const result = await execute();
|
||||
const reader = result.response.body.getReader();
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toBe(": kiro-validation\n\n");
|
||||
|
||||
let settled = false;
|
||||
const semantic = reader.read().then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
upstream.close();
|
||||
expect(new TextDecoder().decode((await semantic).value)).toContain("private until validated");
|
||||
await reader.cancel();
|
||||
});
|
||||
|
||||
it("accepts CLI-compatible text and usage frames at clean EOF without messageStop", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Complete answer." }),
|
||||
frame("meteringEvent", { usage: 2, unit: "credit" }),
|
||||
frame("contextUsageEvent", { contextUsagePercentage: 10 })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("Complete answer.");
|
||||
expect(body).toContain('"finish_reason":"stop"');
|
||||
expect(body).toContain('"kiro_credits":2');
|
||||
});
|
||||
|
||||
it("parses frames split across chunks and multiple frames in one chunk", async () => {
|
||||
const first = frame("assistantResponseEvent", { content: "split " });
|
||||
const second = frame("assistantResponseEvent", { content: "boundaries" });
|
||||
const combined = concat([first, second]);
|
||||
fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(combined.slice(0, 9));
|
||||
controller.enqueue(combined.slice(9, first.byteLength + 5));
|
||||
controller.enqueue(combined.slice(first.byteLength + 5));
|
||||
controller.close();
|
||||
}
|
||||
})));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(body).toContain('"content":"split "');
|
||||
expect(body).toContain('"content":"boundaries"');
|
||||
expect(body).toContain('"finish_reason":"stop"');
|
||||
});
|
||||
|
||||
it("accepts messageStop without semantic output as explicit completion", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([frame("messageStopEvent", {})]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain('"finish_reason":"stop"');
|
||||
expect(body).not.toContain("kiro_missing_terminal");
|
||||
});
|
||||
|
||||
it.each(["...", "…"])("repairs exact ellipsis final %s without leaking it", async (ellipsis) => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: ellipsis })]))
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "Recovered answer." })]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered answer.");
|
||||
expect(body).not.toContain(`"content":"${ellipsis}"`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"接下來我只再確認部署結果。",
|
||||
"我會重新抓取最新日誌並確認結果。",
|
||||
"目前證據顯示只在 **03:48:30–03:49:00 TPE** 出現少量 NonKA 504;主池 106/106、副池 50/50,且兩池都沒有重啟。最後補查 504 access log,確認 host/路徑與是否為集中流量。",
|
||||
"Next I'll verify the deployment logs.",
|
||||
"Let me check the remaining failures."
|
||||
])("repairs conservative future-action final: %s", async (progress) => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: progress })]))
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "Verification completed." })]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Verification completed.");
|
||||
expect(body).not.toContain(progress);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Working...",
|
||||
"I'll check the logs. They show no errors and deployment succeeded.",
|
||||
"Let me check: status is 200 and the checksum matches abc123.",
|
||||
"我會檢查版本。版本是 1.2.3。",
|
||||
"接下來請你先批准部署,我會等待你的確認。",
|
||||
"已完成驗證,所有測試均通過。",
|
||||
"目前證據顯示只有少量 504,且主副池均未重啟。",
|
||||
"目前證據顯示只有少量 504。最後補查結果顯示沒有集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查,結果顯示沒有集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查:結果顯示沒有集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查 504 access log,結果顯示沒有集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查 504 access log,確認 host/路徑與有無集中流量:無集中流量。",
|
||||
"目前證據顯示只有少量 504。最後補查 504 access log,確認 host/路徑與是否為集中流量(答案是否定的)。",
|
||||
"目前證據顯示只有少量 504。最後補充兩點已確認的結果。",
|
||||
"The verification is complete and all tests passed."
|
||||
])("does not retry legitimate final: %s", async (finalText) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: finalText })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(finalText);
|
||||
});
|
||||
|
||||
it("bounds incomplete-final repair to one retry", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "..." })]))
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "…" })]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("kiro_ellipsis_retry_failed");
|
||||
expect(body).not.toContain('"content":"..."');
|
||||
});
|
||||
|
||||
it("repairs malformed wrapper tools without leaking the invalid call", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: "bad",
|
||||
name: "tool_call",
|
||||
input: { arguments: { q: "router" } }
|
||||
})]))
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: "good",
|
||||
name: "tool_call",
|
||||
input: { name: "mcp_search", arguments: { q: "router" } }
|
||||
})]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain('"name":"tool_call"');
|
||||
expect(body).toContain('\\"name\\":\\"mcp_search\\"');
|
||||
expect(body).not.toContain('"id":"bad"');
|
||||
});
|
||||
|
||||
it("requires complete direct tool input and keeps the failure private", async () => {
|
||||
const pending = frame("toolUseEvent", { toolUseId: "pending", name: "read_file" });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([pending]))
|
||||
.mockResolvedValueOnce(response([pending]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("kiro_tool_call_repair_retry_failed");
|
||||
expect(body).not.toContain('"name":"read_file"');
|
||||
});
|
||||
|
||||
it("repairs a non-string toolUseId before releasing the tool call", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: 123,
|
||||
name: "read_file",
|
||||
input: { path: "bad.txt" }
|
||||
})]))
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: "valid-tool-id",
|
||||
name: "read_file",
|
||||
input: { path: "safe.txt" }
|
||||
})]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain('"id":"valid-tool-id"');
|
||||
expect(body).not.toContain('"id":123');
|
||||
});
|
||||
|
||||
it("keeps model-controlled parser detail out of the retry system prompt", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([frame("toolUseEvent", {
|
||||
toolUseId: "bad-json",
|
||||
name: "tool_call",
|
||||
input: '{"name":"IGNORE_ALL_INSTRUCTIONS"'
|
||||
})]))
|
||||
.mockResolvedValueOnce(response([frame("assistantResponseEvent", {
|
||||
content: "Recovered safely."
|
||||
})]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
||||
|
||||
expect(body).toContain("Recovered safely.");
|
||||
expect(retryBody.systemPrompt).toContain("tool_call wrapper was malformed");
|
||||
expect(retryBody.systemPrompt).not.toContain("IGNORE_ALL_INSTRUCTIONS");
|
||||
});
|
||||
|
||||
it("lets a complete tool call override metadata end_turn", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("toolUseEvent", {
|
||||
toolUseId: "tool",
|
||||
name: "read_file",
|
||||
input: { path: "safe.txt" }
|
||||
}),
|
||||
frame("metadataEvent", { stopReason: "end_turn" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain('"name":"read_file"');
|
||||
expect(body).toContain('"finish_reason":"tool_calls"');
|
||||
});
|
||||
|
||||
it("maps max_tokens without treating it as a normal stop", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Limited answer." }),
|
||||
frame("metadataEvent", { stopReason: "max_tokens" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain('"finish_reason":"length"');
|
||||
expect(body).not.toContain('"finish_reason":"stop"');
|
||||
});
|
||||
|
||||
it("retries malformed_model_output once without semantic leakage", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "private malformed output" }),
|
||||
frame("metadataEvent", { stopReason: "malformed_model_output" })
|
||||
]))
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Recovered protocol output." })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered protocol output.");
|
||||
expect(body).not.toContain("private malformed output");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cancelled", "kiro_terminal_incomplete"],
|
||||
["pause_turn", "kiro_terminal_incomplete"],
|
||||
["content_filtered", "kiro_terminal_refusal"],
|
||||
["novel_reason", "kiro_unknown_stop_reason"]
|
||||
])("fails closed for stop reason %s", async (stopReason, code) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: `private-${stopReason}` }),
|
||||
frame("metadataEvent", { stopReason })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(code);
|
||||
expect(body).not.toContain(`private-${stopReason}`);
|
||||
expect(body).not.toContain('"finish_reason":"stop"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
frame("messageStopEvent", { stopReason: "content_filtered" }),
|
||||
frame("metadataEvent", { stopReason: "end_turn" })
|
||||
],
|
||||
[
|
||||
frame("metadataEvent", { stopReason: "end_turn" }),
|
||||
frame("messageStopEvent", { stopReason: "content_filtered" })
|
||||
]
|
||||
])("preserves the most restrictive conflicting stop reason", async (...stopFrames) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "private filtered output" }),
|
||||
...stopFrames
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("kiro_terminal_refusal");
|
||||
expect(body).not.toContain("private filtered output");
|
||||
});
|
||||
|
||||
it("prefers a non-retryable terminal reason over an earlier retryable reason", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "private malformed output" }),
|
||||
frame("metadataEvent", { stopReason: "malformed_model_output" }),
|
||||
frame("messageStopEvent", { stopReason: "cancelled" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("kiro_terminal_incomplete");
|
||||
expect(body).toContain('"stop_reason":"cancelled"');
|
||||
expect(body).not.toContain("private malformed output");
|
||||
});
|
||||
|
||||
it("preserves an authoritative refusal returned by the bounded retry", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "private filtered retry" }),
|
||||
frame("metadataEvent", { stopReason: "content_filtered" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("kiro_terminal_refusal");
|
||||
expect(body).not.toContain("kiro_missing_terminal_retry_failed");
|
||||
expect(body).not.toContain("private filtered retry");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["max_tokens", "kiro_terminal_incomplete"],
|
||||
["cancelled", "kiro_terminal_incomplete"],
|
||||
["content_filtered", "kiro_terminal_refusal"],
|
||||
["novel_reason", "kiro_unknown_stop_reason"]
|
||||
])("does not let a valid tool override failure stop reason %s", async (stopReason, code) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("toolUseEvent", {
|
||||
toolUseId: "blocked-tool",
|
||||
name: "read_file",
|
||||
input: { path: "secret.txt" }
|
||||
}),
|
||||
frame("metadataEvent", { stopReason })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(code);
|
||||
expect(body).not.toContain('"name":"read_file"');
|
||||
});
|
||||
|
||||
it.each(["content_filtered", "cancelled", "max_tokens"])(
|
||||
"classifies failure %s before validating a malformed deferred tool",
|
||||
async (stopReason) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("toolUseEvent", { toolUseId: "bad-tool", name: "read_file" }),
|
||||
frame("metadataEvent", { stopReason })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(stopReason === "content_filtered"
|
||||
? "kiro_terminal_refusal"
|
||||
: "kiro_terminal_incomplete");
|
||||
expect(body).not.toContain("kiro_tool_call_repair_retry_failed");
|
||||
expect(body).not.toContain('"name":"read_file"');
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
["content_filtered", [frame("toolUseEvent", {
|
||||
toolUseId: 123,
|
||||
name: "read_file",
|
||||
input: { path: "bad.txt" }
|
||||
})], "kiro_terminal_refusal"],
|
||||
["cancelled", [frame("toolUseEvent", {
|
||||
toolUseId: "missing-name",
|
||||
input: { path: "bad.txt" }
|
||||
})], "kiro_terminal_incomplete"],
|
||||
["max_tokens", [
|
||||
frame("toolUseEvent", { toolUseId: "changing", name: "read_file" }),
|
||||
frame("toolUseEvent", { toolUseId: "changing", name: "write_file" })
|
||||
], "kiro_terminal_incomplete"]
|
||||
])("continues past eager tool-shape errors to authoritative stop %s", async (stopReason, toolFrames, code) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
...toolFrames,
|
||||
frame("metadataEvent", { stopReason })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain(code);
|
||||
expect(body).not.toContain("kiro_tool_call_repair_retry_failed");
|
||||
expect(body).not.toContain('"tool_calls"');
|
||||
});
|
||||
|
||||
it("retries a TTFT timeout once while preserving cancellation semantics", async () => {
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS = "1";
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(controlledResponse().value)
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Recovered after timeout." })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered after timeout.");
|
||||
});
|
||||
|
||||
it("treats validated non-semantic frames as watchdog activity", async () => {
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS = "30";
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS = "30";
|
||||
const upstream = controlledResponse();
|
||||
fetchMock.mockResolvedValueOnce(upstream.value);
|
||||
setTimeout(() => upstream.enqueue(frame("meteringEvent", { usage: 1 })), 20);
|
||||
setTimeout(() => upstream.enqueue(frame("contextUsageEvent", { contextUsagePercentage: 5 })), 40);
|
||||
setTimeout(() => {
|
||||
upstream.enqueue(frame("assistantResponseEvent", { content: "Completed after active frames." }));
|
||||
upstream.close();
|
||||
}, 60);
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("Completed after active frames.");
|
||||
});
|
||||
|
||||
it("retries a response-body read failure once", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(new Error("socket reset"));
|
||||
}
|
||||
})))
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Recovered after read failure." })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered after read failure.");
|
||||
expect(body).not.toContain("socket reset");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["message CRC", () => {
|
||||
const corrupt = frame("assistantResponseEvent", { content: "corrupt CRC" });
|
||||
corrupt[corrupt.byteLength - 1] ^= 0xff;
|
||||
return [corrupt];
|
||||
}],
|
||||
["prelude CRC", () => {
|
||||
const corrupt = frame("assistantResponseEvent", { content: "corrupt prelude" });
|
||||
corrupt[8] ^= 0xff;
|
||||
return [corrupt];
|
||||
}],
|
||||
["truncated frame", () => {
|
||||
const truncated = frame("assistantResponseEvent", { content: "truncated" });
|
||||
return [truncated.slice(0, -3)];
|
||||
}],
|
||||
["out-of-bounds headers", () => {
|
||||
const corrupt = frame("assistantResponseEvent", { content: "bad headers" });
|
||||
new DataView(corrupt.buffer).setUint32(4, corrupt.byteLength - 15, false);
|
||||
return [checksum(corrupt)];
|
||||
}],
|
||||
["duplicate headers", () => [
|
||||
frameFromEntries([
|
||||
[":event-type", "assistantResponseEvent"],
|
||||
[":event-type", "metadataEvent"]
|
||||
], { content: "duplicate" })
|
||||
]]
|
||||
])("retries %s and releases only the valid attempt", async (_name, invalidFrames) => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "must stay private" }),
|
||||
...invalidFrames()
|
||||
]))
|
||||
.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "Recovered after validation." })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(body).toContain("Recovered after validation.");
|
||||
expect(body).not.toContain("must stay private");
|
||||
});
|
||||
|
||||
it("reports corrupt-frame provenance when the bounded retry also fails", async () => {
|
||||
const corruptFrame = () => {
|
||||
const corrupt = frame("assistantResponseEvent", { content: "corrupt" });
|
||||
corrupt[corrupt.byteLength - 1] ^= 0xff;
|
||||
return corrupt;
|
||||
};
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([corruptFrame()]))
|
||||
.mockResolvedValueOnce(response([corruptFrame()]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(body).toContain("kiro_missing_terminal_retry_failed");
|
||||
expect(body).toContain('"terminal_provenance":"corrupt_eventstream_frame"');
|
||||
expect(body).toContain('"transport_state":"corrupt_frame"');
|
||||
});
|
||||
|
||||
it("caps diagnostic event-type cardinality", async () => {
|
||||
let terminal;
|
||||
const executor = new KiroExecutor();
|
||||
const frames = Array.from({ length: 100 }, (_, index) =>
|
||||
frame(`unknownEvent${index}`, { index })
|
||||
);
|
||||
frames.push(frame("assistantResponseEvent", { content: "done" }));
|
||||
const transformed = executor.transformEventStreamToSSE(
|
||||
response(frames),
|
||||
"kr/claude-opus-4.8",
|
||||
{ onTerminalState: (value) => { terminal = value; } }
|
||||
);
|
||||
|
||||
await transformed.text();
|
||||
|
||||
expect(terminal.event_counts).toEqual({
|
||||
other: 100,
|
||||
assistantResponseEvent: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a raw chunk before concatenating beyond the protocol bound", async () => {
|
||||
let terminal;
|
||||
const executor = new KiroExecutor();
|
||||
const transformed = executor.transformEventStreamToSSE(
|
||||
response([new Uint8Array(65)]),
|
||||
"kr/claude-opus-4.8",
|
||||
{
|
||||
maxRawBytes: 64,
|
||||
onTerminalState: (value) => { terminal = value; }
|
||||
}
|
||||
);
|
||||
|
||||
const body = await transformed.text();
|
||||
|
||||
expect(body).toContain("buffered bytes exceed the protocol bound");
|
||||
expect(terminal.terminal_provenance).toBe("corrupt_eventstream_frame");
|
||||
});
|
||||
|
||||
it.each(["error", "exception"])("propagates EventStream %s without retry or leakage", async (messageType) => {
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "must stay private" }),
|
||||
frameFromEntries([
|
||||
[":message-type", messageType],
|
||||
...(messageType === "exception" ? [[":exception-type", "InternalServerException"]] : [])
|
||||
], { message: "upstream failed" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("kiro_upstream_eventstream_error");
|
||||
expect(body).toContain("upstream failed");
|
||||
expect(body).not.toContain("must stay private");
|
||||
});
|
||||
|
||||
it("surfaces retry HTTP failures as SSE after heartbeat commits headers", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(new Response("unauthorized", {
|
||||
status: 401,
|
||||
statusText: "Unauthorized"
|
||||
}));
|
||||
|
||||
const result = await execute();
|
||||
const body = await result.response.text();
|
||||
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(body).toContain("kiro_integrity_retry_upstream_error");
|
||||
expect(body).toContain("unauthorized");
|
||||
});
|
||||
|
||||
it("bounds the retry HTTP error body", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(response([]))
|
||||
.mockResolvedValueOnce(new Response(`error-start-${"x".repeat(10_000)}-error-tail`, {
|
||||
status: 401,
|
||||
statusText: "Unauthorized"
|
||||
}));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(body).toContain("error-start-");
|
||||
expect(body).not.toContain("error-tail");
|
||||
expect(body.length).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it("propagates cancellation while validation is waiting for EOF", async () => {
|
||||
const upstream = controlledResponse([
|
||||
frame("assistantResponseEvent", { content: "waiting" })
|
||||
]);
|
||||
fetchMock.mockResolvedValueOnce(upstream.value);
|
||||
const abort = new AbortController();
|
||||
|
||||
const result = await execute(new KiroExecutor(), { signal: abort.signal });
|
||||
const reader = result.response.body.getReader();
|
||||
await reader.read();
|
||||
abort.abort("client cancelled");
|
||||
|
||||
await expect(reader.read()).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
|
||||
it("fails safely when the private gate exceeds its configured bound", async () => {
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES = "8";
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("assistantResponseEvent", { content: "larger than eight bytes" })
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("integrity buffer exceeded");
|
||||
expect(body).not.toContain("larger than eight bytes");
|
||||
});
|
||||
|
||||
it("counts deferred tool fragments against the private memory bound", async () => {
|
||||
process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES = "128";
|
||||
fetchMock.mockResolvedValueOnce(response([
|
||||
frame("toolUseEvent", {
|
||||
toolUseId: "large-tool",
|
||||
name: "read_file",
|
||||
input: { path: "x".repeat(200) }
|
||||
})
|
||||
]));
|
||||
|
||||
const body = await (await execute()).response.text();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(body).toContain("kiro_integrity_buffer_exceeded");
|
||||
expect(body).not.toContain('"name":"read_file"');
|
||||
});
|
||||
});
|
||||
@@ -32,10 +32,23 @@ function createMockFrame(eventType, payloadObj) {
|
||||
offset += headerValueBytes.length;
|
||||
|
||||
buffer.set(payloadBytes, offset);
|
||||
|
||||
|
||||
view.setUint32(8, crc32(buffer.subarray(0, 8)), false);
|
||||
view.setUint32(totalLength - 4, crc32(buffer.subarray(0, totalLength - 4)), false);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function crc32(bytes) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
async function readAllSSE(stream) {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -130,14 +143,16 @@ describe("KiroExecutor thinking tag stripping", () => {
|
||||
expect(contentChunks.length).toBe(0);
|
||||
});
|
||||
|
||||
it("emits a terminal chunk at messageStop before the upstream stream closes", async () => {
|
||||
it("waits for clean EOF before emitting stop after messageStop", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
|
||||
const f1 = createMockFrame("assistantResponseEvent", { content: "OK" });
|
||||
const f2 = createMockFrame("messageStopEvent", {});
|
||||
|
||||
let upstreamController;
|
||||
const readableStream = new ReadableStream({
|
||||
start(controller) {
|
||||
upstreamController = controller;
|
||||
controller.enqueue(f1);
|
||||
controller.enqueue(f2);
|
||||
}
|
||||
@@ -147,11 +162,16 @@ describe("KiroExecutor thinking tag stripping", () => {
|
||||
const reader = transformedResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let output = "";
|
||||
for (let i = 0; i < 4 && !output.includes("\"finish_reason\":\"stop\""); i++) {
|
||||
const { value } = await readNextWithTimeout(reader);
|
||||
output += decoder.decode(value, { stream: true });
|
||||
const { value } = await readNextWithTimeout(reader);
|
||||
output += decoder.decode(value, { stream: true });
|
||||
expect(output).not.toContain("\"finish_reason\":\"stop\"");
|
||||
|
||||
upstreamController.close();
|
||||
while (!output.includes("\"finish_reason\":\"stop\"")) {
|
||||
const { value: nextValue, done } = await readNextWithTimeout(reader);
|
||||
if (done) break;
|
||||
output += decoder.decode(nextValue, { stream: true });
|
||||
}
|
||||
await reader.cancel();
|
||||
|
||||
expect(output).toContain("\"finish_reason\":\"stop\"");
|
||||
});
|
||||
|
||||
@@ -316,6 +316,102 @@ describe("openaiToKiroRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["high", "gpt-5.6-sol"],
|
||||
["medium", "kiro/gpt-5.6-terra"],
|
||||
["low", "gpt-5.6-luna"],
|
||||
])("maps GPT-5.6 reasoning.effort %s without legacy prompt tags", (effort, model) => {
|
||||
const body = {
|
||||
reasoning: { effort },
|
||||
messages: [{ role: "user", content: "Use the requested effort" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest(model, body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
reasoning: { effort },
|
||||
});
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
expect(contentOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(contentOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["xhigh", "gpt-5.6-terra", "xhigh"],
|
||||
["max", "gpt-5.6-sol", "xhigh"],
|
||||
])("preserves GPT-5.6 effort %s as supported wire effort %s", (effort, model, wireEffort) => {
|
||||
const body = {
|
||||
reasoning: { effort },
|
||||
messages: [{ role: "user", content: "Use extended effort" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest(model, body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
reasoning: { effort: wireEffort },
|
||||
});
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it("omits GPT-5.6 effort fields and legacy prompt tags when effort is absent", () => {
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "No explicit reasoning effort" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-5.6-sol", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it.each(["auto", "minimal", "ultra"])(
|
||||
"keeps the legacy thinking fallback for unsupported GPT-5.6 effort %s",
|
||||
(effort) => {
|
||||
const body = {
|
||||
reasoning: { effort },
|
||||
messages: [{ role: "user", content: "Use legacy thinking" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-5.6-luna", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["none", "off", "disabled"])(
|
||||
"keeps GPT-5.6 reasoning intentionally disabled for effort %s",
|
||||
(effort) => {
|
||||
const body = {
|
||||
reasoning: { effort },
|
||||
messages: [{ role: "user", content: "Do not reason" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-5.6-luna", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps the thinking-alias fallback when GPT effort is blank", () => {
|
||||
const body = {
|
||||
reasoning: { effort: "" },
|
||||
messages: [{ role: "user", content: "Use the thinking alias" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-5.6-sol-thinking", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
expect(systemPromptOf(result)).toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for legacy Kiro model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
|
||||
Reference in New Issue
Block a user