fix(thinking-block-validator): replace model-name gating with content-based history detection
Replace isExtendedThinkingModel() model-name check with hasSignedThinkingBlocksInHistory()
which scans message history for real Anthropic-signed thinking blocks.
Content-based gating is more robust than model-name checks — works correctly
with custom model IDs, proxied models, and new model releases without code changes.
- Add isSignedThinkingPart() that matches type thinking/redacted_thinking with valid signature
- Skip synthetic parts (injected by previous hook runs)
- GPT reasoning blocks (type=reasoning, no signature) correctly excluded
- Add comprehensive tests: signed injection, redacted_thinking, reasoning negative case, synthetic skip
Inspired by PR #2653 content-based approach, combined with redacted_thinking support from 0732cb85.
Ultraworked with Sisyphus
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,108 +1,184 @@
|
|||||||
const { describe, expect, test } = require("bun:test")
|
declare const describe: (name: string, fn: () => void) => void
|
||||||
|
declare const it: (name: string, fn: () => void | Promise<void>) => void
|
||||||
|
declare const expect: <T>(value: T) => {
|
||||||
|
toBe(expected: T): void
|
||||||
|
toEqual(expected: unknown): void
|
||||||
|
toHaveLength(expected: number): void
|
||||||
|
}
|
||||||
|
|
||||||
const { createThinkingBlockValidatorHook } = require("./hook")
|
import { createThinkingBlockValidatorHook } from "./hook"
|
||||||
|
|
||||||
type TestPart = {
|
type TestPart = {
|
||||||
type: string
|
type: string
|
||||||
id: string
|
|
||||||
text?: string
|
text?: string
|
||||||
thinking?: string
|
thinking?: string
|
||||||
data?: string
|
|
||||||
signature?: string
|
signature?: string
|
||||||
|
synthetic?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type TestMessage = {
|
type TestMessage = {
|
||||||
info: {
|
info: { role: "assistant" | "user" }
|
||||||
role: string
|
|
||||||
id?: string
|
|
||||||
modelID?: string
|
|
||||||
}
|
|
||||||
parts: TestPart[]
|
parts: TestPart[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMessage(info: TestMessage["info"], parts: TestPart[]): TestMessage {
|
async function runTransform(messages: TestMessage[]): Promise<void> {
|
||||||
return { info, parts }
|
const hook = createThinkingBlockValidatorHook()
|
||||||
}
|
const transform = hook["experimental.chat.messages.transform"]
|
||||||
|
|
||||||
function createTextPart(id: string, text: string): TestPart {
|
if (!transform) {
|
||||||
return { type: "text", id, text }
|
throw new Error("missing thinking block validator transform")
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSignedThinkingPart(id: string, thinking: string, signature: string): TestPart {
|
await transform({}, { messages: messages as never })
|
||||||
return { type: "thinking", id, thinking, signature }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRedactedThinkingPart(id: string, signature: string): TestPart {
|
|
||||||
return { type: "redacted_thinking", id, data: "encrypted", signature }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("createThinkingBlockValidatorHook", () => {
|
describe("createThinkingBlockValidatorHook", () => {
|
||||||
test("reuses the previous signed thinking part verbatim when assistant content lacks a leading thinking block", async () => {
|
it("injects signed thinking history verbatim", async () => {
|
||||||
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
|
//#given
|
||||||
expect(typeof transform).toBe("function")
|
const signedThinkingPart: TestPart = {
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "plan",
|
||||||
|
signature: "signed-thinking",
|
||||||
|
}
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [signedThinkingPart],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "continue" }],
|
||||||
|
},
|
||||||
|
] satisfies TestMessage[]
|
||||||
|
|
||||||
const previousThinkingPart = createSignedThinkingPart("prt_prev_signed", "prior reasoning", "sig_prev")
|
//#when
|
||||||
const targetTextPart = createTextPart("prt_target_text", "tool result")
|
await runTransform(messages)
|
||||||
const messages: TestMessage[] = [
|
|
||||||
createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]),
|
|
||||||
createMessage({ role: "assistant", id: "msg_prev" }, [previousThinkingPart, createTextPart("prt_prev_text", "done")]),
|
|
||||||
createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]),
|
|
||||||
]
|
|
||||||
|
|
||||||
await Reflect.apply(transform, undefined, [{}, { messages }])
|
//#then
|
||||||
|
expect(messages[1]?.parts[0]).toBe(signedThinkingPart)
|
||||||
expect(messages[2]?.parts[0]).toBe(previousThinkingPart)
|
|
||||||
expect(messages[2]?.parts).toEqual([previousThinkingPart, targetTextPart])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips injection when no signed Anthropic thinking part exists in history", async () => {
|
it("injects signed redacted_thinking history verbatim", async () => {
|
||||||
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
|
//#given
|
||||||
expect(typeof transform).toBe("function")
|
const signedRedactedThinkingPart: TestPart = {
|
||||||
|
type: "redacted_thinking",
|
||||||
|
signature: "signed-redacted-thinking",
|
||||||
|
}
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [signedRedactedThinkingPart],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "tool_use" }],
|
||||||
|
},
|
||||||
|
] satisfies TestMessage[]
|
||||||
|
|
||||||
const targetTextPart = createTextPart("prt_target_text", "tool result")
|
//#when
|
||||||
const messages: TestMessage[] = [
|
await runTransform(messages)
|
||||||
createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]),
|
|
||||||
createMessage({ role: "assistant", id: "msg_prev" }, [{ type: "reasoning", id: "prt_reason", text: "gpt reasoning" }]),
|
|
||||||
createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]),
|
|
||||||
]
|
|
||||||
|
|
||||||
await Reflect.apply(transform, undefined, [{}, { messages }])
|
//#then
|
||||||
|
expect(messages[1]?.parts[0]).toBe(signedRedactedThinkingPart)
|
||||||
expect(messages[2]?.parts).toEqual([targetTextPart])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not inject when the assistant message already starts with redacted thinking", async () => {
|
it("skips hook when history contains reasoning only", async () => {
|
||||||
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
|
//#given
|
||||||
expect(typeof transform).toBe("function")
|
const reasoningPart: TestPart = {
|
||||||
|
type: "reasoning",
|
||||||
|
text: "internal reasoning",
|
||||||
|
}
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [reasoningPart],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "continue" }],
|
||||||
|
},
|
||||||
|
] satisfies TestMessage[]
|
||||||
|
|
||||||
const existingThinkingPart = createRedactedThinkingPart("prt_redacted", "sig_redacted")
|
//#when
|
||||||
const targetTextPart = createTextPart("prt_target_text", "tool result")
|
await runTransform(messages)
|
||||||
const messages: TestMessage[] = [
|
|
||||||
createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]),
|
|
||||||
createMessage({ role: "assistant", id: "msg_target" }, [existingThinkingPart, targetTextPart]),
|
|
||||||
]
|
|
||||||
|
|
||||||
await Reflect.apply(transform, undefined, [{}, { messages }])
|
//#then
|
||||||
|
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
||||||
expect(messages[1]?.parts).toEqual([existingThinkingPart, targetTextPart])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips processing for models without extended thinking", async () => {
|
it("skips hook when no signed history exists", async () => {
|
||||||
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
|
//#given
|
||||||
expect(typeof transform).toBe("function")
|
const messages = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "thinking", thinking: "draft" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "continue" }],
|
||||||
|
},
|
||||||
|
] satisfies TestMessage[]
|
||||||
|
|
||||||
const previousThinkingPart = createSignedThinkingPart("prt_prev_signed", "prior reasoning", "sig_prev")
|
//#when
|
||||||
const targetTextPart = createTextPart("prt_target_text", "tool result")
|
await runTransform(messages)
|
||||||
const messages: TestMessage[] = [
|
|
||||||
createMessage({ role: "user", modelID: "gpt-5.4" }, [createTextPart("prt_user_text", "continue")]),
|
|
||||||
createMessage({ role: "assistant", id: "msg_prev" }, [previousThinkingPart]),
|
|
||||||
createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]),
|
|
||||||
]
|
|
||||||
|
|
||||||
await Reflect.apply(transform, undefined, [{}, { messages }])
|
//#then
|
||||||
|
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
||||||
|
})
|
||||||
|
|
||||||
expect(messages[2]?.parts).toEqual([targetTextPart])
|
it("skips hook when history contains synthetic signed blocks only", async () => {
|
||||||
|
//#given
|
||||||
|
const syntheticSignedPart: TestPart = {
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "synthetic",
|
||||||
|
signature: "synthetic-signature",
|
||||||
|
synthetic: true,
|
||||||
|
}
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [syntheticSignedPart],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [{ type: "text", text: "continue" }],
|
||||||
|
},
|
||||||
|
] satisfies TestMessage[]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await runTransform(messages)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not reinject when the message already starts with redacted_thinking", async () => {
|
||||||
|
//#given
|
||||||
|
const signedThinkingPart: TestPart = {
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "plan",
|
||||||
|
signature: "signed-thinking",
|
||||||
|
}
|
||||||
|
const leadingRedactedThinkingPart: TestPart = {
|
||||||
|
type: "redacted_thinking",
|
||||||
|
signature: "existing-redacted-thinking",
|
||||||
|
}
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [signedThinkingPart],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: { role: "assistant" },
|
||||||
|
parts: [leadingRedactedThinkingPart, { type: "text", text: "continue" }],
|
||||||
|
},
|
||||||
|
] satisfies TestMessage[]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await runTransform(messages)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(messages[1]?.parts[0]).toBe(leadingRedactedThinkingPart)
|
||||||
|
expect(messages[1]?.parts).toHaveLength(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export {}
|
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ interface MessageWithParts {
|
|||||||
parts: Part[]
|
parts: Part[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignedThinkingPart = Part & {
|
|
||||||
type: "thinking" | "redacted_thinking"
|
|
||||||
signature: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessagesTransformHook = {
|
type MessagesTransformHook = {
|
||||||
"experimental.chat.messages.transform"?: (
|
"experimental.chat.messages.transform"?: (
|
||||||
input: Record<string, never>,
|
input: Record<string, never>,
|
||||||
@@ -33,25 +28,39 @@ type MessagesTransformHook = {
|
|||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type SignedThinkingPart = Part & {
|
||||||
* Check if a model has extended thinking enabled
|
type: "thinking" | "redacted_thinking"
|
||||||
* Uses patterns from think-mode/switcher.ts for consistency
|
thinking?: string
|
||||||
*/
|
signature: string
|
||||||
function isExtendedThinkingModel(modelID: string): boolean {
|
synthetic?: boolean
|
||||||
if (!modelID) return false
|
}
|
||||||
const lower = modelID.toLowerCase()
|
|
||||||
|
|
||||||
// Check for explicit thinking/high variants (always enabled)
|
function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
|
||||||
if (lower.includes("thinking") || lower.endsWith("-high")) {
|
const type = part.type as string
|
||||||
return true
|
if (type !== "thinking" && type !== "redacted_thinking") {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for thinking-capable models (claude-4 family, claude-3)
|
const signature = (part as { signature?: unknown }).signature
|
||||||
// Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts
|
const synthetic = (part as { synthetic?: unknown }).synthetic
|
||||||
return (
|
return typeof signature === "string" && signature.length > 0 && synthetic !== true
|
||||||
lower.includes("claude-sonnet-4") ||
|
}
|
||||||
lower.includes("claude-opus-4") ||
|
|
||||||
lower.includes("claude-3")
|
/**
|
||||||
|
* Check if there are any Anthropic-signed thinking blocks in the message history.
|
||||||
|
*
|
||||||
|
* Only returns true for real `type: "thinking"` blocks with a valid `signature`.
|
||||||
|
* GPT reasoning blocks (`type: "reasoning"`) are intentionally excluded — they
|
||||||
|
* have no Anthropic signature and must never be forwarded to the Anthropic API.
|
||||||
|
*
|
||||||
|
* Model-name checks are unreliable (miss GPT+thinking, custom model IDs, etc.)
|
||||||
|
* so we inspect the messages themselves.
|
||||||
|
*/
|
||||||
|
function hasSignedThinkingBlocksInHistory(messages: MessageWithParts[]): boolean {
|
||||||
|
return messages.some(
|
||||||
|
m =>
|
||||||
|
m.info.role === "assistant" &&
|
||||||
|
m.parts?.some((p: Part) => isSignedThinkingPart(p)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,36 +88,42 @@ function startsWithThinkingBlock(parts: Part[]): boolean {
|
|||||||
return type === "thinking" || type === "redacted_thinking" || type === "reasoning"
|
return type === "thinking" || type === "redacted_thinking" || type === "reasoning"
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
|
/**
|
||||||
const type = part.type as string
|
* Find the most recent Anthropic-signed thinking part from previous assistant messages.
|
||||||
if (type !== "thinking" && type !== "redacted_thinking") {
|
*
|
||||||
return false
|
* Returns the original Part object (including its `signature` field) so it can
|
||||||
}
|
* be reused verbatim in another message. Only `type: "thinking"` blocks with
|
||||||
|
* both a `signature` and `thinking` field are returned — GPT `type: "reasoning"`
|
||||||
const signature = (part as { signature?: unknown }).signature
|
* blocks are excluded because they lack an Anthropic signature and would be
|
||||||
return typeof signature === "string" && signature.length > 0
|
* rejected by the API with "Invalid `signature` in `thinking` block".
|
||||||
}
|
* Synthetic parts injected by a previous run of this hook are also skipped.
|
||||||
|
*/
|
||||||
function findPreviousThinkingPart(
|
function findPreviousThinkingPart(messages: MessageWithParts[], currentIndex: number): SignedThinkingPart | null {
|
||||||
messages: MessageWithParts[],
|
|
||||||
currentIndex: number
|
|
||||||
): SignedThinkingPart | null {
|
|
||||||
// Search backwards from current message
|
// Search backwards from current message
|
||||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||||
const msg = messages[i]
|
const msg = messages[i]
|
||||||
if (msg.info.role !== "assistant") continue
|
if (msg.info.role !== "assistant") continue
|
||||||
|
|
||||||
if (!msg.parts) continue
|
if (!msg.parts) continue
|
||||||
|
|
||||||
for (const part of msg.parts) {
|
for (const part of msg.parts) {
|
||||||
if (isSignedThinkingPart(part)) {
|
// Only Anthropic thinking blocks — type must be "thinking", not "reasoning"
|
||||||
return part
|
if (!isSignedThinkingPart(part)) continue
|
||||||
}
|
|
||||||
|
return part
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepend an existing thinking block (with its original signature) to a
|
||||||
|
* message's parts array.
|
||||||
|
*
|
||||||
|
* We reuse the original Part verbatim instead of creating a new one, because
|
||||||
|
* the Anthropic API validates the `signature` field against the thinking
|
||||||
|
* content. Any synthetic block we create ourselves would fail that check.
|
||||||
|
*/
|
||||||
function prependThinkingBlock(message: MessageWithParts, thinkingPart: SignedThinkingPart): void {
|
function prependThinkingBlock(message: MessageWithParts, thinkingPart: SignedThinkingPart): void {
|
||||||
if (!message.parts) {
|
if (!message.parts) {
|
||||||
message.parts = []
|
message.parts = []
|
||||||
@@ -129,13 +144,12 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the model info from the last user message
|
// Skip if there are no Anthropic-signed thinking blocks in history.
|
||||||
const lastUserMessage = messages.findLast(m => m.info.role === "user")
|
// This is more reliable than checking model names — works for Claude,
|
||||||
const modelIDValue = (lastUserMessage?.info as { modelID?: unknown } | undefined)?.modelID
|
// GPT with thinking variants, or any future model. Crucially, GPT
|
||||||
const modelID = typeof modelIDValue === "string" ? modelIDValue : ""
|
// reasoning blocks (type="reasoning", no signature) do NOT trigger this
|
||||||
|
// hook — only real Anthropic thinking blocks do.
|
||||||
// Only process if extended thinking might be enabled
|
if (!hasSignedThinkingBlocksInHistory(messages)) {
|
||||||
if (!isExtendedThinkingModel(modelID)) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,12 +162,18 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
|||||||
|
|
||||||
// Check if message has content parts but doesn't start with thinking
|
// Check if message has content parts but doesn't start with thinking
|
||||||
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||||
|
// Find the most recent real thinking part (with valid signature) from
|
||||||
|
// previous turns. If none exists we cannot safely inject a thinking
|
||||||
|
// block — a synthetic block without a signature would cause the API
|
||||||
|
// to reject the request with "Invalid `signature` in `thinking` block".
|
||||||
const previousThinkingPart = findPreviousThinkingPart(messages, i)
|
const previousThinkingPart = findPreviousThinkingPart(messages, i)
|
||||||
if (!previousThinkingPart) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
prependThinkingBlock(msg, previousThinkingPart)
|
if (previousThinkingPart) {
|
||||||
|
prependThinkingBlock(msg, previousThinkingPart)
|
||||||
|
}
|
||||||
|
// If no real thinking part is available, skip injection entirely.
|
||||||
|
// The downstream error (if any) is preferable to a guaranteed API
|
||||||
|
// rejection caused by a signature-less synthetic thinking block.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user