f04cc0fa9c
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>
185 lines
4.6 KiB
TypeScript
185 lines
4.6 KiB
TypeScript
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
|
|
}
|
|
|
|
import { createThinkingBlockValidatorHook } from "./hook"
|
|
|
|
type TestPart = {
|
|
type: string
|
|
text?: string
|
|
thinking?: string
|
|
signature?: string
|
|
synthetic?: boolean
|
|
}
|
|
|
|
type TestMessage = {
|
|
info: { role: "assistant" | "user" }
|
|
parts: TestPart[]
|
|
}
|
|
|
|
async function runTransform(messages: TestMessage[]): Promise<void> {
|
|
const hook = createThinkingBlockValidatorHook()
|
|
const transform = hook["experimental.chat.messages.transform"]
|
|
|
|
if (!transform) {
|
|
throw new Error("missing thinking block validator transform")
|
|
}
|
|
|
|
await transform({}, { messages: messages as never })
|
|
}
|
|
|
|
describe("createThinkingBlockValidatorHook", () => {
|
|
it("injects signed thinking history verbatim", async () => {
|
|
//#given
|
|
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[]
|
|
|
|
//#when
|
|
await runTransform(messages)
|
|
|
|
//#then
|
|
expect(messages[1]?.parts[0]).toBe(signedThinkingPart)
|
|
})
|
|
|
|
it("injects signed redacted_thinking history verbatim", async () => {
|
|
//#given
|
|
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[]
|
|
|
|
//#when
|
|
await runTransform(messages)
|
|
|
|
//#then
|
|
expect(messages[1]?.parts[0]).toBe(signedRedactedThinkingPart)
|
|
})
|
|
|
|
it("skips hook when history contains reasoning only", async () => {
|
|
//#given
|
|
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[]
|
|
|
|
//#when
|
|
await runTransform(messages)
|
|
|
|
//#then
|
|
expect(messages[1]?.parts).toEqual([{ type: "text", text: "continue" }])
|
|
})
|
|
|
|
it("skips hook when no signed history exists", async () => {
|
|
//#given
|
|
const messages = [
|
|
{
|
|
info: { role: "assistant" },
|
|
parts: [{ type: "thinking", thinking: "draft" }],
|
|
},
|
|
{
|
|
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("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)
|
|
})
|
|
})
|