69a4b2f49c
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).
168 lines
4.7 KiB
TypeScript
168 lines
4.7 KiB
TypeScript
import { describe, it, expect } from "bun:test"
|
|
|
|
import { createMessagesTransformHandler } from "./messages-transform"
|
|
import { createToolPairValidatorHook } from "../hooks/tool-pair-validator/hook"
|
|
import type { CreatedHooks } from "../create-hooks"
|
|
|
|
type TestPart = {
|
|
type: string
|
|
id?: string
|
|
callID?: string
|
|
tool_use_id?: string
|
|
content?: string
|
|
text?: string
|
|
}
|
|
|
|
type TestMessage = {
|
|
info: { role: "assistant" | "user" }
|
|
parts: TestPart[]
|
|
}
|
|
|
|
type TransformHook = (
|
|
input: Record<string, never>,
|
|
output: { messages: TestMessage[] },
|
|
) => Promise<void>
|
|
|
|
function makeHook(handler: TransformHook): NonNullable<CreatedHooks["toolPairValidator"]> {
|
|
return {
|
|
"experimental.chat.messages.transform": handler as never,
|
|
} as never
|
|
}
|
|
|
|
function makeHooks(overrides: {
|
|
contextInjector?: TransformHook
|
|
thinkingBlock?: TransformHook
|
|
toolPair?: TransformHook
|
|
}): CreatedHooks {
|
|
return {
|
|
contextInjectorMessagesTransform: overrides.contextInjector ? makeHook(overrides.contextInjector) : undefined,
|
|
thinkingBlockValidator: overrides.thinkingBlock ? makeHook(overrides.thinkingBlock) : undefined,
|
|
toolPairValidator: overrides.toolPair ? makeHook(overrides.toolPair) : undefined,
|
|
} as unknown as CreatedHooks
|
|
}
|
|
|
|
async function runHandler(
|
|
hooks: CreatedHooks,
|
|
messages: TestMessage[],
|
|
): Promise<void> {
|
|
const handler = createMessagesTransformHandler({ hooks })
|
|
await handler({} as never, { messages: messages as never })
|
|
}
|
|
|
|
describe("createMessagesTransformHandler", () => {
|
|
it("runs all hooks in order when none throw", async () => {
|
|
//#given
|
|
const callOrder: string[] = []
|
|
const hooks = makeHooks({
|
|
contextInjector: async () => {
|
|
callOrder.push("context-injector")
|
|
},
|
|
thinkingBlock: async () => {
|
|
callOrder.push("thinking-block-validator")
|
|
},
|
|
toolPair: async () => {
|
|
callOrder.push("tool-pair-validator")
|
|
},
|
|
})
|
|
|
|
//#when
|
|
await runHandler(hooks, [])
|
|
|
|
//#then
|
|
expect(callOrder).toEqual([
|
|
"context-injector",
|
|
"thinking-block-validator",
|
|
"tool-pair-validator",
|
|
])
|
|
})
|
|
|
|
it("runs tool-pair-validator even when context-injector throws", async () => {
|
|
//#given
|
|
let toolPairRan = false
|
|
const hooks = makeHooks({
|
|
contextInjector: async () => {
|
|
throw new Error("context-injector boom")
|
|
},
|
|
toolPair: async () => {
|
|
toolPairRan = true
|
|
},
|
|
})
|
|
|
|
//#when
|
|
await runHandler(hooks, [])
|
|
|
|
//#then
|
|
expect(toolPairRan).toBe(true)
|
|
})
|
|
|
|
it("runs tool-pair-validator even when thinking-block-validator throws", async () => {
|
|
//#given
|
|
let toolPairRan = false
|
|
const hooks = makeHooks({
|
|
thinkingBlock: async () => {
|
|
throw new Error("thinking-block boom")
|
|
},
|
|
toolPair: async () => {
|
|
toolPairRan = true
|
|
},
|
|
})
|
|
|
|
//#when
|
|
await runHandler(hooks, [])
|
|
|
|
//#then
|
|
expect(toolPairRan).toBe(true)
|
|
})
|
|
|
|
it("repairs orphaned tool_use after upstream hook throws (regression for ses_22bd806)", async () => {
|
|
//#given
|
|
const messages: TestMessage[] = [
|
|
{ info: { role: "user" }, parts: [{ type: "text", text: "summary stand-in" }] },
|
|
{ info: { role: "assistant" }, parts: [{ type: "tool_use", id: "toolu_01SRMQs3DUtVKWoSxC8bxxVA" }] },
|
|
{ info: { role: "assistant" }, parts: [{ type: "tool_use", id: "toolu_01Lu5cHvRtEvzoifP1UVBVRb" }] },
|
|
{ info: { role: "user" }, parts: [{ type: "text", text: "next" }] },
|
|
]
|
|
const hooks = makeHooks({
|
|
contextInjector: async () => {
|
|
throw new Error("simulating upstream hook failure")
|
|
},
|
|
toolPair: createRealToolPairValidator(),
|
|
})
|
|
|
|
//#when
|
|
await runHandler(hooks, messages)
|
|
|
|
//#then
|
|
expect(messages).toHaveLength(5)
|
|
expect(messages[2]).toEqual({
|
|
info: { role: "user" },
|
|
parts: [{ type: "tool_result", tool_use_id: "toolu_01SRMQs3DUtVKWoSxC8bxxVA", content: "Tool output unavailable (context compacted)" }],
|
|
})
|
|
expect(messages[4]?.parts[0]).toEqual({
|
|
type: "tool_result",
|
|
tool_use_id: "toolu_01Lu5cHvRtEvzoifP1UVBVRb",
|
|
content: "Tool output unavailable (context compacted)",
|
|
})
|
|
expect(messages[4]?.parts[1]).toEqual({ type: "text", text: "next" })
|
|
})
|
|
|
|
it("does not throw when tool-pair-validator itself fails", async () => {
|
|
//#given
|
|
const hooks = makeHooks({
|
|
toolPair: async () => {
|
|
throw new Error("validator boom")
|
|
},
|
|
})
|
|
|
|
//#when / #then
|
|
await runHandler(hooks, [])
|
|
})
|
|
})
|
|
|
|
function createRealToolPairValidator(): TransformHook {
|
|
const validator = createToolPairValidatorHook()
|
|
const handler = validator["experimental.chat.messages.transform"]
|
|
if (!handler) throw new Error("validator missing transform")
|
|
return handler as never
|
|
}
|