Fix Kimi Coding tool-call replay (#82550)

Summary:
- The PR preserves Kimi Coding reasoning_content replay for OpenAI-compatible tool-call follow-up turns, extends replay model-id matching, adds Kimi wrapper/tests, and updates the changelog.
- Reproducibility: yes. at source level: current main drops or fails to synthesize reasoning_content for kimi- ... es a concrete Kimi 400 after tool-call history. I did not run a live Kimi request in this read-only review.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

Validation:
- ClawSweeper review passed for head 9a4605ee38.
- Required merge gates passed before the squash merge.

Prepared head SHA: 9a4605ee38
Review: https://github.com/openclaw/openclaw/pull/82550#issuecomment-4466701075

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
This commit is contained in:
Alex Knight
2026-05-16 21:54:46 +10:00
committed by GitHub
parent 9558b2c222
commit f8b7008f7c
7 changed files with 221 additions and 11 deletions

View File

@@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai
- Providers/polling: reject array, null, or scalar successful operation status responses with provider-owned malformed JSON errors instead of waiting until timeout.
- ACPX/Codex: reap plugin-local Codex ACP adapter orphans on startup after wrapper crashes while keeping direct adapter commands out of launch-lease injection. Fixes #82364. (#82459) Thanks @joshavant.
- Telegram: send presentation-only payloads by rendering fallback text and inline buttons instead of treating them as empty. Fixes #82404. (#82449) Thanks @joshavant.
- Providers/Kimi: preserve Kimi Coding `reasoning_content` replay and backfill assistant tool-call placeholders when thinking is enabled, so `kimi-for-coding` follow-up tool turns no longer fail after prior tool use. Fixes #82161. Thanks @amknight.
- Providers/search tools: reject malformed successful xAI, Gemini, and Kimi web/code search responses with provider-owned errors instead of silent `No response` payloads or ungrounded fallback state.
- Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export.
- Voice calls: persist rejected inbound-call replay keys so duplicate carrier webhook retries stay ignored after a Gateway restart.

View File

@@ -307,6 +307,126 @@ describe("kimi tool-call markup wrapper", () => {
});
});
it("backfills Kimi OpenAI-compatible tool-call reasoning_content when thinking is enabled", () => {
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
messages: [
{ role: "user", content: "run pwd" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "exec", arguments: "{\"command\":\"pwd\"}" },
},
],
},
{
role: "assistant",
content: "kept",
reasoning_content: "native reasoning",
tool_calls: [
{
id: "call_2",
type: "function",
function: { name: "read", arguments: "{}" },
},
],
},
],
});
const wrapped = createKimiThinkingWrapper(baseStreamFn, "enabled");
void wrapped(
{
api: "openai-completions",
provider: "kimi",
id: "kimi-for-coding",
} as Model<"openai-completions">,
{ messages: [] } as Context,
{},
);
expect(getCapturedPayload()).toEqual({
messages: [
{ role: "user", content: "run pwd" },
{
role: "assistant",
content: null,
reasoning_content: "",
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "exec", arguments: "{\"command\":\"pwd\"}" },
},
],
},
{
role: "assistant",
content: "kept",
reasoning_content: "native reasoning",
tool_calls: [
{
id: "call_2",
type: "function",
function: { name: "read", arguments: "{}" },
},
],
},
],
thinking: { type: "enabled" },
});
});
it("strips Kimi OpenAI-compatible replay reasoning_content when thinking is disabled", () => {
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
messages: [
{
role: "assistant",
content: null,
reasoning_content: "old reasoning",
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "exec", arguments: "{\"command\":\"pwd\"}" },
},
],
},
],
});
const wrapped = createKimiThinkingWrapper(baseStreamFn, "disabled");
void wrapped(
{
api: "openai-completions",
provider: "kimi",
id: "kimi-for-coding",
} as Model<"openai-completions">,
{ messages: [] } as Context,
{},
);
expect(getCapturedPayload()).toEqual({
messages: [
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "exec", arguments: "{\"command\":\"pwd\"}" },
},
],
},
],
thinking: { type: "disabled" },
});
});
it("enables Kimi Anthropic thinking with a high budget and enough output room", () => {
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();

View File

@@ -75,6 +75,39 @@ function ensureKimiAnthropicMaxTokens(
payloadObj.max_tokens = current === undefined ? required : Math.max(current, required);
}
function messageHasOpenAIToolCalls(message: Record<string, unknown>): boolean {
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
}
function ensureKimiOpenAIReasoningContent(payloadObj: Record<string, unknown>): void {
if (!Array.isArray(payloadObj.messages)) {
return;
}
for (const message of payloadObj.messages) {
if (!message || typeof message !== "object") {
continue;
}
const record = message as Record<string, unknown>;
if (record.role !== "assistant" || !messageHasOpenAIToolCalls(record)) {
continue;
}
if (!("reasoning_content" in record)) {
record.reasoning_content = "";
}
}
}
function stripKimiOpenAIReasoningContent(payloadObj: Record<string, unknown>): void {
if (!Array.isArray(payloadObj.messages)) {
return;
}
for (const message of payloadObj.messages) {
if (message && typeof message === "object") {
delete (message as Record<string, unknown>).reasoning_content;
}
}
}
function normalizeKimiThinkingType(value: unknown): KimiThinkingType | undefined {
if (typeof value === "boolean") {
return value ? "enabled" : "disabled";
@@ -331,6 +364,10 @@ export function createKimiThinkingWrapper(
model.api === "anthropic-messages" ? { ...normalized } : { type: normalized.type };
if (model.api === "anthropic-messages") {
ensureKimiAnthropicMaxTokens(payloadObj, normalized);
} else if (normalized.type === "enabled") {
ensureKimiOpenAIReasoningContent(payloadObj);
} else {
stripKimiOpenAIReasoningContent(payloadObj);
}
delete payloadObj.reasoning;
delete payloadObj.reasoning_effort;

View File

@@ -5725,6 +5725,14 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () =>
maxTokens: 32_000,
} satisfies Model<"openai-completions">;
const kimiCodingProxyModel = {
...customKimiProxyModel,
id: "kimi-for-coding",
name: "Kimi for Coding",
provider: "kimi",
baseUrl: "https://api.kimi.com/coding/v1",
} satisfies Model<"openai-completions">;
function getAssistantMessage(params: { messages: unknown }) {
expect(Array.isArray(params.messages)).toBe(true);
const list = params.messages as Array<Record<string, unknown>>;
@@ -5916,6 +5924,17 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () =>
expect(assistant).not.toHaveProperty("reasoning_text");
});
it("preserves reasoning_content replay for Kimi Coding OpenAI-compatible routes", () => {
const assistant = getAssistantMessage(
buildReplayParams(kimiCodingProxyModel, "reasoning_content"),
);
expect(assistant.reasoning_content).toBe("Need to answer politely.");
expect(assistant).not.toHaveProperty("reasoning_details");
expect(assistant).not.toHaveProperty("reasoning");
expect(assistant).not.toHaveProperty("reasoning_text");
});
it("preserves reasoning_content replay for suffixed reasoning model ids", () => {
const assistant = getAssistantMessage(
buildReplayParams(
@@ -5930,6 +5949,20 @@ describe("buildOpenAICompletionsParams sanitizes reasoning replay fields", () =>
expect(assistant.reasoning_content).toBe("Need to answer politely.");
});
it("preserves reasoning_content replay for prefixed reasoning model ids", () => {
const assistant = getAssistantMessage(
buildReplayParams(
{
...customKimiProxyModel,
id: "hf:moonshotai/kimi-k2-thinking",
},
"reasoning_content",
),
);
expect(assistant.reasoning_content).toBe("Need to answer politely.");
});
it("preserves OpenRouter array reasoning_details from tool-call signatures", () => {
const reasoningDetail = { type: "reasoning.encrypted", id: "rs_1", data: "ciphertext" };
const params = buildOpenAICompletionsParams(

View File

@@ -2552,6 +2552,7 @@ function sanitizeReasoningContentReplayFields(record: Record<string, unknown>):
const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
"deepseek-v4-flash",
"deepseek-v4-pro",
"kimi-for-coding",
"kimi-k2.5",
"kimi-k2.6",
"kimi-k2-thinking",
@@ -2563,16 +2564,22 @@ const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
"mimo-v2.6-pro",
]);
function normalizeReasoningContentReplayModelId(modelId: unknown): string | undefined {
function getReasoningContentReplayModelIdCandidates(modelId: unknown): string[] {
if (typeof modelId !== "string") {
return undefined;
return [];
}
const normalized = modelId.trim().toLowerCase().split(":", 1)[0];
const normalized = modelId.trim().toLowerCase();
if (!normalized) {
return undefined;
return [];
}
const parts = normalized.split("/").filter(Boolean);
return parts[parts.length - 1] ?? normalized;
const finalPart = parts[parts.length - 1] ?? normalized;
const candidates = [finalPart];
const colonParts = finalPart.split(":").filter(Boolean);
if (colonParts.length > 1) {
candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? "");
}
return [...new Set(candidates.filter(Boolean))];
}
function shouldPreserveReasoningContentReplay(
@@ -2586,9 +2593,8 @@ function shouldPreserveReasoningContentReplay(
) {
return true;
}
const normalizedModelId = normalizeReasoningContentReplayModelId(model.id);
return (
normalizedModelId !== undefined && REASONING_CONTENT_REPLAY_MODEL_IDS.has(normalizedModelId)
return getReasoningContentReplayModelIdCandidates(model.id).some((modelId) =>
REASONING_CONTENT_REPLAY_MODEL_IDS.has(modelId),
);
}

View File

@@ -362,7 +362,14 @@ describe("resolveTranscriptPolicy", () => {
expect(responsesPolicy.dropReasoningFromHistory).toBe(false);
});
it.each(["moonshotai/kimi-k2.6", "kimi-k2-thinking", "xiaomi/mimo-v2.6-pro"])(
it.each([
"kimi-for-coding",
"moonshotai/kimi-k2.6",
"kimi-k2-thinking",
"hf:moonshotai/kimi-k2-thinking",
"xiaomi/mimo-v2.6-pro",
"xiaomi/mimo-v2.6-pro:cloud",
])(
"preserves historical reasoning for %s replay-required OpenAI-compatible models",
(modelId) => {
const policy = resolveTranscriptPolicy({

View File

@@ -158,6 +158,7 @@ function buildUnownedProviderTransportReplayFallback(params: {
}
const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
"kimi-for-coding",
"kimi-k2.5",
"kimi-k2.6",
"kimi-k2-thinking",
@@ -170,13 +171,18 @@ const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
]);
function requiresReasoningContentReplay(modelId: string | null | undefined): boolean {
const normalized = normalizeLowercaseStringOrEmpty(modelId).split(":", 1)[0];
const normalized = normalizeLowercaseStringOrEmpty(modelId);
if (!normalized) {
return false;
}
const parts = normalized.split("/").filter(Boolean);
const finalPart = parts[parts.length - 1] ?? normalized;
return REASONING_CONTENT_REPLAY_MODEL_IDS.has(finalPart);
const candidates = [finalPart];
const colonParts = finalPart.split(":").filter(Boolean);
if (colonParts.length > 1) {
candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? "");
}
return candidates.some((candidate) => REASONING_CONTENT_REPLAY_MODEL_IDS.has(candidate));
}
function mergeTranscriptPolicy(