Files
openclaw/extensions/kimi-coding/kimi-coding.live.test.ts
Peter Steinberger a327e43023 fix(kimi): import isLiveTestEnabled from plugin-sdk/test-live
The live-test helper is exported from openclaw/plugin-sdk/test-live (as every
other extension live test imports it), not test-env. The wrong subpath in
#109335 broke check-test-types repo-wide.
2026-07-16 14:09:22 -07:00

188 lines
5.8 KiB
TypeScript

import {
streamSimple,
type AssistantMessage,
type Context,
type Model,
type Tool,
} from "openclaw/plugin-sdk/llm";
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-live";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
import { buildKimiCodingProvider } from "./provider-catalog.js";
const describeLive =
isLiveTestEnabled() && process.env.KIMI_API_KEY?.trim() ? describe : describe.skip;
async function collectDoneMessage(
stream: AsyncIterable<{ type: string; message?: AssistantMessage; error?: AssistantMessage }>,
): Promise<AssistantMessage> {
let doneMessage: AssistantMessage | undefined;
for await (const event of stream) {
if (event.type === "error") {
throw new Error(event.error?.errorMessage || "Kimi live request failed");
}
if (event.type === "done") {
doneMessage = event.message;
}
}
if (!doneMessage) {
throw new Error("Kimi live stream ended without a done message");
}
return doneMessage;
}
function resolveModel(modelId: "k3" | "k3[1m]"): Model<"anthropic-messages"> {
const provider = buildKimiCodingProvider();
const definition = provider.models.find((model) => model.id === modelId);
if (!definition) {
throw new Error(`Missing model ${modelId}`);
}
return {
provider: "kimi",
baseUrl: provider.baseUrl,
headers: provider.headers,
...definition,
api: "anthropic-messages",
} as Model<"anthropic-messages">;
}
function countContentChars(message: AssistantMessage, type: "text" | "thinking"): number {
return message.content.reduce((total, block) => {
if (type === "text" && block.type === "text") {
return total + block.text.length;
}
if (type === "thinking" && block.type === "thinking") {
return total + block.thinking.length;
}
return total;
}, 0);
}
async function runReasoningScenario(params: {
modelId: "k3" | "k3[1m]";
thinkingLevel: "off" | "max";
}): Promise<AssistantMessage> {
const registered = await registerSingleProviderPlugin(plugin);
const wrapped = registered.wrapStreamFn?.({
provider: "kimi",
modelId: params.modelId,
thinkingLevel: params.thinkingLevel,
extraParams: { thinking: params.thinkingLevel === "off" ? "off" : "enabled" },
streamFn: streamSimple,
} as never);
if (!wrapped) {
throw new Error("Missing Kimi stream wrapper");
}
const context: Context = {
messages: [
{
role: "user",
content: "Reply with exactly LIVE_OK and no punctuation.",
timestamp: Date.now(),
},
],
};
return collectDoneMessage(
wrapped(resolveModel(params.modelId), context, {
apiKey: process.env.KIMI_API_KEY?.trim() ?? "",
maxTokens: 4096,
}) as AsyncIterable<{
type: string;
message?: AssistantMessage;
error?: AssistantMessage;
}>,
);
}
describeLive("Kimi Code K3 reasoning live", () => {
it.each(["k3", "k3[1m]"] as const)(
"%s honors off and max reasoning",
async (modelId) => {
const off = await runReasoningScenario({ modelId, thinkingLevel: "off" });
expect(countContentChars(off, "thinking")).toBe(0);
expect(countContentChars(off, "text")).toBeGreaterThan(0);
const max = await runReasoningScenario({ modelId, thinkingLevel: "max" });
expect(countContentChars(max, "thinking")).toBeGreaterThan(0);
expect(countContentChars(max, "text")).toBeGreaterThan(0);
},
180_000,
);
it("preserves reasoning across a K3 tool-result replay", async () => {
const registered = await registerSingleProviderPlugin(plugin);
const wrapped = registered.wrapStreamFn?.({
provider: "kimi",
modelId: "k3",
thinkingLevel: "max",
streamFn: streamSimple,
} as never);
if (!wrapped) {
throw new Error("Missing Kimi stream wrapper");
}
const tool: Tool = {
name: "noop",
description: "Return ok.",
parameters: Type.Object({}, { additionalProperties: false }),
};
const firstUser = {
role: "user" as const,
content: "Call the noop tool with an empty object. Do not answer directly.",
timestamp: Date.now(),
};
const first = await collectDoneMessage(
wrapped(
resolveModel("k3"),
{ messages: [firstUser], tools: [tool] },
{ apiKey: process.env.KIMI_API_KEY?.trim() ?? "", maxTokens: 4096 },
) as AsyncIterable<{
type: string;
message?: AssistantMessage;
error?: AssistantMessage;
}>,
);
expect(countContentChars(first, "thinking")).toBeGreaterThan(0);
const toolCall = first.content.find((block) => block.type === "toolCall");
if (!toolCall || toolCall.type !== "toolCall") {
throw new Error(`Kimi K3 did not call noop: ${first.stopReason}`);
}
expect(toolCall.name).toBe("noop");
const second = await collectDoneMessage(
wrapped(
resolveModel("k3"),
{
messages: [
firstUser,
first,
{
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: Date.now(),
},
{
role: "user",
content: "Reply with exactly LIVE_OK and no punctuation.",
timestamp: Date.now(),
},
],
tools: [tool],
},
{ apiKey: process.env.KIMI_API_KEY?.trim() ?? "", maxTokens: 4096 },
) as AsyncIterable<{
type: string;
message?: AssistantMessage;
error?: AssistantMessage;
}>,
);
expect(countContentChars(second, "text")).toBeGreaterThan(0);
}, 180_000);
});