fix(messages-transform): isolate hook failures so tool-pair-validator always runs

Previously each transform hook was awaited sequentially without per-hook
error handling. If contextInjectorMessagesTransform or thinkingBlockValidator
threw, toolPairValidator was silently skipped, leaving orphaned tool_use
blocks in the post-compaction API payload and producing
"messages.N: tool_use ids were found without tool_result blocks immediately
after" 400s from Anthropic.

Wraps each hook in runHookSafely so an upstream throw is logged but the
chain continues. Adds regression tests covering the isolation contract and
the consecutive-assistants compaction tail case (ses_22bd806).
This commit is contained in:
mrosnerr
2026-04-28 14:51:38 -04:00
parent b8652db5e5
commit 69a4b2f49c
2 changed files with 213 additions and 9 deletions
+46 -9
View File
@@ -1,5 +1,6 @@
import type { Message, Part } from "@opencode-ai/sdk"
import { log } from "../shared/logger"
import type { CreatedHooks } from "../create-hooks"
type MessageWithParts = {
@@ -9,20 +10,56 @@ type MessageWithParts = {
type MessagesTransformOutput = { messages: MessageWithParts[] }
async function runMessagesTransformHookSafely<I, O>(
hookName: string,
handler: ((input: I, output: O) => unknown | Promise<unknown>) | null | undefined,
input: I,
output: O,
): Promise<void> {
if (!handler) return
try {
await Promise.resolve(handler(input, output))
} catch (error) {
// Isolate per-handler failures so later handlers (notably toolPairValidator)
// always run. A throw here used to leave orphaned tool_use blocks in the
// post-compaction payload, producing API 400s like
// "tool_use ids were found without tool_result blocks immediately after".
log("[messages-transform] hook execution failed", {
hook: hookName,
error,
})
}
}
export function createMessagesTransformHandler(args: {
hooks: CreatedHooks
}): (input: Record<string, never>, output: MessagesTransformOutput) => Promise<void> {
return async (input, output): Promise<void> => {
await args.hooks.contextInjectorMessagesTransform?.[
"experimental.chat.messages.transform"
]?.(input, output)
await runMessagesTransformHookSafely(
"contextInjectorMessagesTransform",
args.hooks.contextInjectorMessagesTransform?.[
"experimental.chat.messages.transform"
],
input,
output,
)
await args.hooks.thinkingBlockValidator?.[
"experimental.chat.messages.transform"
]?.(input, output)
await runMessagesTransformHookSafely(
"thinkingBlockValidator",
args.hooks.thinkingBlockValidator?.[
"experimental.chat.messages.transform"
],
input,
output,
)
await args.hooks.toolPairValidator?.[
"experimental.chat.messages.transform"
]?.(input, output)
await runMessagesTransformHookSafely(
"toolPairValidator",
args.hooks.toolPairValidator?.[
"experimental.chat.messages.transform"
],
input,
output,
)
}
}