Files
oh-my-opencode/src/hooks/thinking-block-validator/hook.ts
T
2026-05-26 01:29:23 +09:00

183 lines
6.3 KiB
TypeScript

/**
* Proactive Thinking Block Validator Hook
*
* Prevents "Expected thinking/redacted_thinking but found tool_use" errors
* by validating and fixing message structure BEFORE sending to Anthropic API.
*
* This hook runs on the "experimental.chat.messages.transform" hook point,
* which is called before messages are converted to ModelMessage format and
* sent to the API.
*
* Key differences from session-recovery hook:
* - PROACTIVE (prevents error) vs REACTIVE (fixes after error)
* - Runs BEFORE API call vs AFTER API error
* - User never sees the error vs User sees error then recovery
*/
import type { Message, Part } from "@opencode-ai/sdk"
interface MessageWithParts {
info: Message
parts: Part[]
}
type MessagesTransformHook = {
"experimental.chat.messages.transform"?: (
input: Record<string, never>,
output: { messages: MessageWithParts[] }
) => Promise<void>
}
type SignedThinkingPart = Part & {
type: "thinking" | "redacted_thinking"
thinking?: string
signature: string
synthetic?: boolean
}
function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
const type = part.type as string
if (type !== "thinking" && type !== "redacted_thinking") {
return false
}
const signature = (part as { signature?: unknown }).signature
const synthetic = (part as { synthetic?: unknown }).synthetic
return typeof signature === "string" && signature.length > 0 && synthetic !== true
}
/**
* 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)),
)
}
/**
* Check if a message has any content parts (tool_use, text, or other non-thinking content)
*/
function hasContentParts(parts: Part[]): boolean {
if (!parts || parts.length === 0) return false
return parts.some((part: Part) => {
const type = part.type as string
// Include tool parts and text parts (anything that's not thinking/reasoning)
return type === "tool" || type === "tool_use" || type === "text"
})
}
/**
* Check if a message already carries a thinking/reasoning block anywhere.
*/
function hasThinkingBlock(parts: Part[]): boolean {
if (!parts || parts.length === 0) return false
return parts.some((part) => {
const type = part.type as string
return type === "thinking" || type === "redacted_thinking" || type === "reasoning"
})
}
/**
* Find the most recent Anthropic-signed thinking part from previous assistant messages.
*
* 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"`
* blocks are excluded because they lack an Anthropic signature and would be
* 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(messages: MessageWithParts[], currentIndex: number): SignedThinkingPart | null {
// Search backwards from current message
for (let i = currentIndex - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role !== "assistant") continue
if (!msg.parts) continue
for (const part of msg.parts) {
// Only Anthropic thinking blocks - type must be "thinking", not "reasoning"
if (!isSignedThinkingPart(part)) continue
return part
}
}
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 {
if (!message.parts) {
message.parts = []
}
message.parts.unshift(thinkingPart)
}
/**
* Validate and fix assistant messages that have tool_use but no thinking block
*/
export function createThinkingBlockValidatorHook(): MessagesTransformHook {
return {
"experimental.chat.messages.transform": async (_input, output) => {
const { messages } = output
if (!messages || messages.length === 0) {
return
}
// Skip if there are no Anthropic-signed thinking blocks in history.
// This is more reliable than checking model names - works for Claude,
// GPT with thinking variants, or any future model. Crucially, GPT
// reasoning blocks (type="reasoning", no signature) do NOT trigger this
// hook - only real Anthropic thinking blocks do.
if (!hasSignedThinkingBlocksInHistory(messages)) {
return
}
// Process all assistant messages
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
// Only check assistant messages
if (msg.info.role !== "assistant") continue
// Check if message has content parts but no thinking block yet.
if (hasContentParts(msg.parts) && !hasThinkingBlock(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)
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.
}
}
},
}
}