feat(hooks): add empty-message-sanitizer to prevent API errors from empty chat messages

Add new hook that uses the `experimental.chat.messages.transform` hook to prevent 'non-empty content' API errors by injecting placeholder text into empty messages BEFORE they're sent to the API.

This is a preventive fix - unlike session-recovery which fixes errors after they occur, this hook prevents the error from happening by sanitizing messages before API transmission.

Files:
- src/hooks/empty-message-sanitizer/index.ts (new hook implementation)
- src/hooks/index.ts (export hook function)
- src/config/schema.ts (add hook to HookName type)
- src/index.ts (wire up hook to plugin)

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-16 21:02:38 +09:00
parent 3761d45712
commit 7293b8845d
4 changed files with 105 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
import type { Message, Part } from "@opencode-ai/sdk"
const PLACEHOLDER_TEXT = "[user interrupted]"
interface MessageWithParts {
info: Message
parts: Part[]
}
type MessagesTransformHook = {
"experimental.chat.messages.transform"?: (
input: Record<string, never>,
output: { messages: MessageWithParts[] }
) => Promise<void>
}
function hasTextContent(part: Part): boolean {
if (part.type === "text") {
const text = (part as unknown as { text?: string }).text
return Boolean(text && text.trim().length > 0)
}
return false
}
function isToolPart(part: Part): boolean {
const type = part.type as string
return type === "tool" || type === "tool_use" || type === "tool_result"
}
function hasValidContent(parts: Part[]): boolean {
return parts.some((part) => hasTextContent(part) || isToolPart(part))
}
export function createEmptyMessageSanitizerHook(): MessagesTransformHook {
return {
"experimental.chat.messages.transform": async (_input, output) => {
const { messages } = output
for (const message of messages) {
if (message.info.role === "user") continue
const parts = message.parts
if (!hasValidContent(parts) && parts.length > 0) {
let injected = false
for (const part of parts) {
if (part.type === "text") {
const textPart = part as unknown as { text?: string; synthetic?: boolean }
if (!textPart.text || !textPart.text.trim()) {
textPart.text = PLACEHOLDER_TEXT
textPart.synthetic = true
injected = true
break
}
}
}
if (!injected) {
const insertIndex = parts.findIndex((p) => isToolPart(p))
const newPart = {
id: `synthetic_${Date.now()}`,
messageID: message.info.id,
sessionID: (message.info as unknown as { sessionID?: string }).sessionID ?? "",
type: "text" as const,
text: PLACEHOLDER_TEXT,
synthetic: true,
}
if (insertIndex === -1) {
parts.push(newPart as Part)
} else {
parts.splice(insertIndex, 0, newPart as Part)
}
}
}
for (const part of parts) {
if (part.type === "text") {
const textPart = part as unknown as { text?: string; synthetic?: boolean }
if (textPart.text !== undefined && textPart.text.trim() === "") {
textPart.text = PLACEHOLDER_TEXT
textPart.synthetic = true
}
}
}
}
},
}
}

View File

@@ -19,3 +19,4 @@ export { createAgentUsageReminderHook } from "./agent-usage-reminder";
export { createKeywordDetectorHook } from "./keyword-detector";
export { createNonInteractiveEnvHook } from "./non-interactive-env";
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";