fix(claudeHelper): preserve latest assistant thinking blocks verbatim

Derived from squash commit 161cfcf7 (PR #9). The original squash was fat
(316 files) because the source branch was rebased on an old base; this
commit applies only the claudeHelper-relevant files surgically onto deploy.

Computes latestAssistantIndex once before the message loop and skips
the rewrite-to-redacted-thinking transform on the latest assistant
message. Symmetric guard for non-Anthropic Claude-shape providers
preserves plain thinking.thinking text on the latest message.

Co-authored-by: OmniRoute Ops <ops@nomenak.dev>
This commit is contained in:
OmniRoute Ops
2026-05-13 11:57:42 +00:00
parent 26d6b7a76f
commit 6f2b360ce8
3 changed files with 440 additions and 98 deletions

View File

@@ -1,5 +1,15 @@
// Claude helper functions for translator
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
import { lookupReasoning, recordReplay } from "../../services/reasoningCache.ts";
// Placeholder thinking text used as last-resort fallback when:
// - Target upstream is a non-Anthropic Claude-shape provider
// (kimi-coding, glmt, zai, …) that rejects redacted_thinking blobs
// - Client (e.g. Capy) sent only redacted_thinking on replay
// - reasoningCache has no entry for the corresponding tool_use.id
// Must be non-empty: kimi-coding treats empty `thinking.thinking` as
// `reasoning_content missing` and 400s.
const NON_ANTHROPIC_THINKING_PLACEHOLDER = "(prior reasoning summary unavailable)";
type ClaudeContentBlock = {
type?: string;
@@ -151,6 +161,17 @@ export function prepareClaudeRequest(
const supportsPromptCaching =
provider === "claude" || provider?.startsWith?.("anthropic-compatible-");
// Non-Anthropic Claude-shape providers (kimi-coding, glmt, zai, …) cannot
// validate the synthetic redacted_thinking.data blob — they're not Anthropic
// and don't speak its signature scheme. They expect plain `thinking { text }`
// blocks with the original reasoning text, or fail with:
// "thinking is enabled but reasoning_content is missing in assistant
// tool call message at index N"
// We use the same allowlist as prompt-caching: only Anthropic-native
// upstreams get redacted_thinking. Everything else gets plain thinking blocks
// backed by reasoningCache (real text) or a placeholder (cache miss).
const supportsRedactedThinking = supportsPromptCaching;
const systemBlocks = body.system;
if (systemBlocks && Array.isArray(systemBlocks) && !preserveCacheControl) {
body.system = systemBlocks.map((block, i) => {
@@ -233,6 +254,19 @@ export function prepareClaudeRequest(
}
// Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic
// Index of the LAST assistant message in the filtered array. Anthropic
// enforces the latest assistant message's thinking blocks cannot be
// modified — preserve them verbatim. Older assistant messages can be
// rewritten to redacted_thinking { data } as before.
let latestAssistantIndex = -1;
for (let k = filtered.length - 1; k >= 0; k--) {
if (filtered[k]?.role === "assistant") {
latestAssistantIndex = k;
break;
}
}
let lastAssistantProcessed = false;
for (let i = filtered.length - 1; i >= 0; i--) {
const msg = filtered[i];
@@ -264,46 +298,132 @@ export function prepareClaudeRequest(
// assistant tool call message at index N" (kimi-coding)
// "Invalid signature in thinking block" (claude native, on
// cross-provider replay)
// Guard: never modify EXISTING thinking blocks in the latest assistant
// message when sending to an Anthropic-native upstream. Anthropic returns
// 400 "blocks in the latest assistant message cannot be modified" if any
// field changes. Injecting a NEW thinking block (when none exists) is fine.
// Older assistant messages can still be rewritten.
// For non-Anthropic providers: only the text replacement is skipped
// for the latest assistant (if it already has non-empty thinking text);
// field cleanup (signature strip, type normalization) still runs.
const isLatestAssistant = i === latestAssistantIndex;
const latestHasExistingThinking =
isLatestAssistant &&
content.some((b: any) => b.type === "thinking" || b.type === "redacted_thinking");
if (latestHasExistingThinking && supportsRedactedThinking) {
// Anthropic: skip all thinking-block rewrites entirely — the
// blocks must remain verbatim (type, thinking, signature, data).
continue;
}
let hasToolUse = false;
let hasThinking = false;
// Convert thinking blocks to redacted_thinking with synthetic `data`.
// When requests cross provider boundaries (e.g., combo fallback) or
// when client-stored signatures (Capy) replay back to Anthropic, the
// original `thinking.signature` no longer validates: "Invalid signature
// in thinking block" 400. redacted_thinking accepts without signature
// validation — but Anthropic REQUIRES a `data` field on it. Previous
// versions emitted `signature` on redacted_thinking (wrong field,
// belongs on regular `thinking`) and omitted `data`, causing:
// messages.N.content.0.redacted_thinking.data: Field required (400)
// Pre-collect tool_use ids in this content[] for reasoningCache
// lookups when the upstream is a non-Anthropic Claude-shape provider.
// The cache is keyed by tool_call_id which equals tool_use.id for
// Anthropic-shape (the same value is reused across formats — see
// claude-to-openai.ts:63 where openai tool_call.id = claude tool_use.id).
const toolUseIds: string[] = [];
if (!supportsRedactedThinking) {
for (const block of content) {
if (block.type === "tool_use" && typeof block.id === "string") {
toolUseIds.push(block.id);
}
}
}
// Convert thinking blocks per provider type:
//
// Fix: emit only the correct fields per type.
// - redacted_thinking: { type, data }
// - thinking: { type, thinking, signature }
// We use DEFAULT_THINKING_CLAUDE_SIGNATURE as the `data` placeholder
// — it's a proven Anthropic-format base64 blob accepted as a valid
// redacted_thinking payload (replay context).
// Anthropic-native (claude, anthropic-compatible-*):
// Emit redacted_thinking { data } with synthetic blob. Anthropic
// accepts this as a valid placeholder for replay context without
// re-validating the original signature. Previous behavior — keep.
//
// When requests cross provider boundaries (e.g., combo fallback) or
// when client-stored signatures (Capy) replay back to Anthropic, the
// original `thinking.signature` no longer validates: "Invalid
// signature in thinking block" 400. redacted_thinking accepts without
// signature validation — but Anthropic REQUIRES a `data` field.
// Field rules: redacted_thinking={type,data} ; thinking={type,thinking,signature}.
//
// Non-Anthropic Claude-shape (kimi-coding, glmt, zai, …):
// Emit plain thinking { thinking: <text> } using the real reasoning
// text from reasoningCache (captured on the prior assistant
// response). Falls back to NON_ANTHROPIC_THINKING_PLACEHOLDER if the
// cache misses (rare but possible after a process restart or TTL
// eviction). Empty text is treated as "missing" by kimi-coding so
// never emit an empty thinking field.
let thinkingBlockIdx = 0;
for (const block of content) {
if (block.type === "thinking" || block.type === "redacted_thinking") {
block.type = "redacted_thinking";
block.data = DEFAULT_THINKING_CLAUDE_SIGNATURE;
delete block.thinking;
delete block.signature;
if (supportsRedactedThinking) {
block.type = "redacted_thinking";
block.data = DEFAULT_THINKING_CLAUDE_SIGNATURE;
delete block.thinking;
delete block.signature;
} else {
const existing =
typeof block.thinking === "string" && block.thinking.length > 0
? block.thinking
: "";
let text = existing;
// For the latest assistant message on non-Anthropic upstreams,
// preserve the thinking text verbatim when it is already present.
// Cache lookups and the placeholder fallback only apply to older
// messages (or to the latest if the client sent empty text).
if (!text || !latestHasExistingThinking) {
if (!text) {
const pairedToolUseId = toolUseIds[thinkingBlockIdx];
if (pairedToolUseId) {
const cached = lookupReasoning(pairedToolUseId);
if (cached) {
text = cached;
recordReplay();
}
}
}
block.type = "thinking";
block.thinking = text || NON_ANTHROPIC_THINKING_PLACEHOLDER;
} else {
// latestHasExistingThinking + non-empty text: preserve text, still clean up fields
block.type = "thinking";
}
delete block.data;
delete block.signature;
}
hasThinking = true;
thinkingBlockIdx++;
}
if (block.type === "tool_use") hasToolUse = true;
}
// Add thinking block if thinking enabled + has tool_use but no thinking.
// Required for Anthropic-shape thinking-mode upstreams (claude, kimi,
// glm) when the assistant turn's content[] needs a precursor thinking
// block in front of any tool_use. Use redacted_thinking shape (with
// `data`) to match what we emit when converting real thinking blocks.
// Add precursor thinking block if thinking enabled + has tool_use but
// no existing thinking-ish block. Required for Anthropic-shape
// thinking-mode upstreams (claude, kimi-coding, glm, …) when the
// assistant turn's content[] needs a thinking block in front of any
// tool_use. Use the same provider-aware shape selection as above.
if (thinkingEnabled && !hasThinking && hasToolUse) {
content.unshift({
type: "redacted_thinking",
data: DEFAULT_THINKING_CLAUDE_SIGNATURE,
});
if (supportsRedactedThinking) {
content.unshift({
type: "redacted_thinking",
data: DEFAULT_THINKING_CLAUDE_SIGNATURE,
});
} else {
let text = "";
const firstToolUseId = toolUseIds[0];
if (firstToolUseId) {
const cached = lookupReasoning(firstToolUseId);
if (cached) {
text = cached;
recordReplay();
}
}
content.unshift({
type: "thinking",
thinking: text || NON_ANTHROPIC_THINKING_PLACEHOLDER,
});
}
}
}
}

View File

@@ -4,6 +4,10 @@ import assert from "node:assert/strict";
const { prepareClaudeRequest } = await import("../../open-sse/translator/helpers/claudeHelper.ts");
const { DEFAULT_THINKING_CLAUDE_SIGNATURE } =
await import("../../open-sse/config/defaultThinkingSignature.ts");
const reasoningCache = await import("../../open-sse/services/reasoningCache.ts");
// Placeholder string from claudeHelper.ts — kept in sync via direct constant.
const PLACEHOLDER = "(prior reasoning summary unavailable)";
function multiTurnBodyWithoutThinkingBlock() {
return {
@@ -16,46 +20,149 @@ function multiTurnBodyWithoutThinkingBlock() {
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_x",
content: "README.md\npackage.json",
},
],
content: [{ type: "tool_result", tool_use_id: "call_x", content: "README.md" }],
},
],
};
}
test("prepareClaudeRequest: claude provider — injects thinking before tool_use (regression)", () => {
function multiTurnBodyWithThinkingBlock(thinkingText: string, toolUseId = "call_y") {
return {
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [
{ role: "user", content: [{ type: "text", text: "hi" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: thinkingText, signature: "client-stored-sig" },
{ type: "tool_use", id: toolUseId, name: "ls", input: {} },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: toolUseId, content: "ok" }],
},
],
};
}
// ──────────────── Anthropic-native (claude, anthropic-compatible-*) ────────────────
test("claude provider — empty content, injects redacted_thinking{data} before tool_use", () => {
const body = multiTurnBodyWithoutThinkingBlock();
const result = prepareClaudeRequest(body as any, "claude");
const assistantContent = (result as any).messages[1].content;
assert.equal(assistantContent.length, 2, "thinking + tool_use");
assert.equal(assistantContent[0].type, "redacted_thinking");
assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(assistantContent[0].thinking, undefined);
assert.equal(assistantContent[0].signature, undefined);
assert.equal(assistantContent[1].type, "tool_use");
const content = (result as any).messages[1].content;
assert.equal(content.length, 2);
assert.equal(content[0].type, "redacted_thinking");
assert.equal(content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(content[0].thinking, undefined);
assert.equal(content[0].signature, undefined);
assert.equal(content[1].type, "tool_use");
});
test("prepareClaudeRequest: kimi-coding provider — injects thinking before tool_use (new behavior)", () => {
// Previously: kimi-coding was excluded by the provider gate and the assistant
// turn shipped to api.kimi.com/coding/v1/messages without a thinking
// precursor, triggering 400 "thinking is enabled but reasoning_content is
// missing in assistant tool call message at index N".
test("claude provider — existing thinking block converted to redacted_thinking{data} on older messages", () => {
// Uses a two-assistant-turn body: the first assistant (with thinking) is an
// older turn; the second (latest) assistant's thinking must stay verbatim.
// This verifies that older assistant thinking blocks ARE rewritten.
const body: any = {
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [
{ role: "user", content: [{ type: "text", text: "hi" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "real thinking text", signature: "client-stored-sig" },
{ type: "tool_use", id: "call_y", name: "ls", input: {} },
],
},
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_y", content: "ok" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "latest thinking", signature: "latest-sig" },
{ type: "tool_use", id: "call_z", name: "ls", input: {} },
],
},
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_z", content: "ok" }] },
],
};
prepareClaudeRequest(body, "claude");
// Older assistant: thinking rewritten to redacted_thinking
const olderContent = body.messages[1].content;
assert.equal(olderContent.length, 2, "no double-inject");
assert.equal(olderContent[0].type, "redacted_thinking");
assert.equal(olderContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(
olderContent[0].thinking,
undefined,
"plain text stripped (Anthropic does not trust replay text)"
);
assert.equal(olderContent[0].signature, undefined);
assert.equal(olderContent[1].type, "tool_use");
// Latest assistant: thinking preserved verbatim
const latestContent = body.messages[3].content;
assert.equal(latestContent[0].type, "thinking");
assert.equal(latestContent[0].thinking, "latest thinking");
assert.equal(latestContent[0].signature, "latest-sig");
});
test("anthropic-compatible-* provider — same as claude (redacted_thinking)", () => {
const body = multiTurnBodyWithoutThinkingBlock();
const result = prepareClaudeRequest(body as any, "anthropic-compatible-abc123");
const content = (result as any).messages[1].content;
assert.equal(content[0].type, "redacted_thinking");
assert.equal(content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
});
// ──────────────── Non-Anthropic Claude-shape (kimi-coding, glmt, zai, …) ────────────────
test("kimi-coding provider — empty content, injects plain thinking{text} with placeholder (cache miss)", () => {
reasoningCache.clearReasoningCacheAll();
const body = multiTurnBodyWithoutThinkingBlock();
const result = prepareClaudeRequest(body as any, "kimi-coding");
const assistantContent = (result as any).messages[1].content;
assert.equal(assistantContent.length, 2, "thinking + tool_use");
assert.equal(assistantContent[0].type, "redacted_thinking");
assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(assistantContent[0].thinking, undefined);
assert.equal(assistantContent[0].signature, undefined);
const content = (result as any).messages[1].content;
assert.equal(content.length, 2);
assert.equal(content[0].type, "thinking");
assert.equal(content[0].thinking, PLACEHOLDER);
assert.equal(content[0].data, undefined, "no data field on plain thinking");
assert.equal(content[0].signature, undefined, "no signature field on cross-provider replay");
assert.equal(content[1].type, "tool_use");
});
test("prepareClaudeRequest: existing thinking block — redacted, signature replaced, no double-inject", () => {
test("kimi-coding provider — empty content + cache hit on tool_use.id, injects real reasoning text", () => {
reasoningCache.clearReasoningCacheAll();
reasoningCache.cacheReasoning(
"call_x",
"kimi-coding",
"kimi-k2.6",
"the model actually thought this"
);
const body = multiTurnBodyWithoutThinkingBlock();
const result = prepareClaudeRequest(body as any, "kimi-coding");
const content = (result as any).messages[1].content;
assert.equal(content[0].type, "thinking");
assert.equal(content[0].thinking, "the model actually thought this");
});
test("kimi-coding provider — existing thinking block: client text preserved, signature stripped, data NOT added", () => {
reasoningCache.clearReasoningCacheAll();
const body = multiTurnBodyWithThinkingBlock("client preserved reasoning", "call_y");
const result = prepareClaudeRequest(body as any, "kimi-coding");
const content = (result as any).messages[1].content;
assert.equal(content.length, 2);
assert.equal(content[0].type, "thinking");
assert.equal(content[0].thinking, "client preserved reasoning", "client text preserved");
assert.equal(content[0].data, undefined);
assert.equal(
content[0].signature,
undefined,
"client-stored signature stripped (no value for kimi)"
);
});
test("kimi-coding provider — existing redacted_thinking block (no text), cache hit injects real text", () => {
reasoningCache.clearReasoningCacheAll();
reasoningCache.cacheReasoning("call_z", "kimi-coding", "kimi-k2.6", "cached reasoning v2");
const body = {
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [
@@ -63,35 +170,10 @@ test("prepareClaudeRequest: existing thinking block — redacted, signature repl
{
role: "assistant",
content: [
{ type: "thinking", thinking: "reasoning here", signature: "old-sig" },
{ type: "tool_use", id: "call_y", name: "ls", input: {} },
{ type: "redacted_thinking", data: "opaque-blob-from-prior-anthropic-turn" },
{ type: "tool_use", id: "call_z", name: "ls", input: {} },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "call_y", content: "ok" }],
},
],
};
const result = prepareClaudeRequest(body as any, "kimi-coding");
const assistantContent = (result as any).messages[1].content;
assert.equal(assistantContent.length, 2, "no double-inject — exactly 2 blocks");
assert.equal(assistantContent[0].type, "redacted_thinking", "thinking → redacted_thinking");
assert.equal(assistantContent[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(assistantContent[0].thinking, undefined, "thinking field stripped");
assert.equal(assistantContent[0].signature, undefined, "signature field stripped");
assert.equal(assistantContent[1].type, "tool_use");
});
test("prepareClaudeRequest: thinking disabled — no inject regardless of tool_use presence", () => {
const body = {
thinking: { type: "disabled" },
messages: [
{ role: "user", content: [{ type: "text", text: "hi" }] },
{
role: "assistant",
content: [{ type: "tool_use", id: "call_z", name: "ls", input: {} }],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "call_z", content: "ok" }],
@@ -99,20 +181,151 @@ test("prepareClaudeRequest: thinking disabled — no inject regardless of tool_u
],
};
const result = prepareClaudeRequest(body as any, "kimi-coding");
const assistantContent = (result as any).messages[1].content;
assert.equal(assistantContent.length, 1, "no thinking injected when thinking is disabled");
assert.equal(assistantContent[0].type, "tool_use");
const content = (result as any).messages[1].content;
assert.equal(content[0].type, "thinking");
assert.equal(content[0].thinking, "cached reasoning v2", "cache substitutes redacted data");
assert.equal(content[0].data, undefined);
});
test("prepareClaudeRequest: thinking enabled + no tool_use — no inject (single-turn text)", () => {
test("kimi-coding provider — existing redacted_thinking block (no text), cache miss → placeholder", () => {
reasoningCache.clearReasoningCacheAll();
const body = {
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
messages: [
{ role: "user", content: [{ type: "text", text: "hi" }] },
{
role: "assistant",
content: [
{ type: "redacted_thinking", data: "opaque-blob" },
{ type: "tool_use", id: "call_z_miss", name: "ls", input: {} },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "call_z_miss", content: "ok" }],
},
],
};
const result = prepareClaudeRequest(body as any, "kimi-coding");
const userContent = (result as any).messages[0].content;
assert.ok(Array.isArray(userContent));
assert.equal(userContent[0].type, "text");
// No new thinking block prepended on user messages
assert.equal(userContent.length, 1);
const content = (result as any).messages[1].content;
assert.equal(content[0].type, "thinking");
assert.equal(content[0].thinking, PLACEHOLDER);
});
// ──────────────── Disabled / no-op paths ────────────────
test("thinking disabled — no inject regardless of provider or tool_use", () => {
for (const provider of ["claude", "kimi-coding", "anthropic-compatible-x"]) {
const body = {
thinking: { type: "disabled" },
messages: [
{ role: "user", content: [{ type: "text", text: "hi" }] },
{ role: "assistant", content: [{ type: "tool_use", id: "x", name: "ls", input: {} }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "x", content: "ok" }] },
],
};
const result = prepareClaudeRequest(body as any, provider);
const content = (result as any).messages[1].content;
assert.equal(content.length, 1, `${provider}: no inject when thinking disabled`);
assert.equal(content[0].type, "tool_use");
}
});
test("thinking enabled + no tool_use — no precursor inject (single-turn text)", () => {
for (const provider of ["claude", "kimi-coding"]) {
const body = {
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
};
const result = prepareClaudeRequest(body as any, provider);
const content = (result as any).messages[0].content;
assert.ok(Array.isArray(content));
assert.equal(content.length, 1);
assert.equal(content[0].type, "text");
}
});
// ──────────────── Latest-assistant preservation (Anthropic & non-Anthropic) ────────────────
test("preserves verbatim thinking on the LATEST assistant message; rewrites only older ones", () => {
const body: any = {
thinking: { type: "enabled", budget_tokens: 4096 },
model: "claude-opus-4-7",
messages: [
{
role: "assistant",
content: [
{ type: "thinking", thinking: "older thought", signature: "sig-OLD" },
{ type: "tool_use", id: "tool_1", name: "do_x", input: {} },
],
},
{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool_1", content: "ok" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "latest thought", signature: "sig-LATEST" },
{ type: "tool_use", id: "tool_2", name: "do_y", input: {} },
],
},
{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool_2", content: "ok" }] },
],
};
prepareClaudeRequest(body, "claude");
const olderAssistant = body.messages[0];
const latestAssistant = body.messages[2];
// Older assistant thinking: rewritten to redacted_thinking { data }
assert.equal(olderAssistant.content[0].type, "redacted_thinking");
assert.ok(
typeof olderAssistant.content[0].data === "string" && olderAssistant.content[0].data.length > 0
);
assert.equal(olderAssistant.content[0].thinking, undefined);
assert.equal(olderAssistant.content[0].signature, undefined);
// Latest assistant thinking: untouched (type, text, signature all preserved)
assert.equal(latestAssistant.content[0].type, "thinking");
assert.equal(latestAssistant.content[0].thinking, "latest thought");
assert.equal(latestAssistant.content[0].signature, "sig-LATEST");
assert.equal(latestAssistant.content[0].data, undefined);
});
test("non-Anthropic upstream: preserves latest assistant thinking text verbatim, only fills older from cache/placeholder", () => {
reasoningCache.clearReasoningCacheAll();
const body: any = {
thinking: { type: "enabled", budget_tokens: 4096 },
model: "kimi-k2.6-thinking",
messages: [
{
role: "assistant",
content: [
{ type: "thinking", thinking: "" /* stripped on wire */ },
{ type: "tool_use", id: "tool_1", name: "do_x", input: {} },
],
},
{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool_1", content: "ok" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "latest reasoning text" },
{ type: "tool_use", id: "tool_2", name: "do_y", input: {} },
],
},
{ role: "user", content: [{ type: "tool_result", tool_use_id: "tool_2", content: "ok" }] },
],
};
prepareClaudeRequest(body, "kimi-coding");
// Latest assistant: text preserved verbatim
assert.equal(body.messages[2].content[0].type, "thinking");
assert.equal(body.messages[2].content[0].thinking, "latest reasoning text");
// Older assistant: empty text -> placeholder (cache miss path)
assert.equal(body.messages[0].content[0].type, "thinking");
assert.ok(
typeof body.messages[0].content[0].thinking === "string" &&
body.messages[0].content[0].thinking.length > 0
);
});

View File

@@ -6,8 +6,6 @@ const openaiHelper = await import("../../open-sse/translator/helpers/openaiHelpe
const claudeHelper = await import("../../open-sse/translator/helpers/claudeHelper.ts");
const geminiHelper = await import("../../open-sse/translator/helpers/geminiHelper.ts");
const toolCallHelper = await import("../../open-sse/translator/helpers/toolCallHelper.ts");
const { DEFAULT_THINKING_CLAUDE_SIGNATURE } =
await import("../../open-sse/config/defaultThinkingSignature.ts");
const originalMathRandom = Math.random;
@@ -306,13 +304,24 @@ test("claudeHelper validates content, ordering and request preparation branches"
assert.equal(prepared.messages.length, 6);
assert.equal(prepared.messages[2].content.at(-1).cache_control.type, "ephemeral");
assert.equal(prepared.messages[4].content[0].type, "tool_result");
// messages[5] is the latest (and last) assistant message; Anthropic enforces
// that its thinking blocks must remain verbatim — not rewritten to
// redacted_thinking. The guard in prepareClaudeRequest preserves them.
assert.deepEqual(
prepared.messages[5].content.map((block) => block.type),
["redacted_thinking", "text"]
["thinking", "text"]
);
assert.equal(prepared.messages[5].content[0].thinking, "old", "thinking text preserved verbatim");
assert.equal(
prepared.messages[5].content[0].signature,
"replace",
"signature preserved verbatim"
);
assert.equal(
prepared.messages[5].content[0].data,
undefined,
"no data field on verbatim thinking"
);
assert.equal(prepared.messages[5].content[0].data, DEFAULT_THINKING_CLAUDE_SIGNATURE);
assert.equal(prepared.messages[5].content[0].signature, undefined);
assert.equal(prepared.messages[5].content[0].thinking, undefined);
assert.equal(prepared.tools.length, 2);
assert.equal(prepared.tools[0].cache_control, undefined);
assert.deepEqual(prepared.tools[1].cache_control, { type: "ephemeral", ttl: "1h" });