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:
@@ -62,6 +62,7 @@ export const HookNameSchema = z.enum([
|
|||||||
"agent-usage-reminder",
|
"agent-usage-reminder",
|
||||||
"non-interactive-env",
|
"non-interactive-env",
|
||||||
"interactive-bash-session",
|
"interactive-bash-session",
|
||||||
|
"empty-message-sanitizer",
|
||||||
])
|
])
|
||||||
|
|
||||||
export const AgentOverrideConfigSchema = z.object({
|
export const AgentOverrideConfigSchema = z.object({
|
||||||
|
|||||||
91
src/hooks/empty-message-sanitizer/index.ts
Normal file
91
src/hooks/empty-message-sanitizer/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,3 +19,4 @@ export { createAgentUsageReminderHook } from "./agent-usage-reminder";
|
|||||||
export { createKeywordDetectorHook } from "./keyword-detector";
|
export { createKeywordDetectorHook } from "./keyword-detector";
|
||||||
export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
||||||
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
||||||
|
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||||
|
|||||||
12
src/index.ts
12
src/index.ts
@@ -20,6 +20,7 @@ import {
|
|||||||
createAgentUsageReminderHook,
|
createAgentUsageReminderHook,
|
||||||
createNonInteractiveEnvHook,
|
createNonInteractiveEnvHook,
|
||||||
createInteractiveBashSessionHook,
|
createInteractiveBashSessionHook,
|
||||||
|
createEmptyMessageSanitizerHook,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||||
import {
|
import {
|
||||||
@@ -246,6 +247,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
const interactiveBashSession = isHookEnabled("interactive-bash-session")
|
const interactiveBashSession = isHookEnabled("interactive-bash-session")
|
||||||
? createInteractiveBashSessionHook(ctx)
|
? createInteractiveBashSessionHook(ctx)
|
||||||
: null;
|
: null;
|
||||||
|
const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer")
|
||||||
|
? createEmptyMessageSanitizerHook()
|
||||||
|
: null;
|
||||||
|
|
||||||
updateTerminalTitle({ sessionId: "main" });
|
updateTerminalTitle({ sessionId: "main" });
|
||||||
|
|
||||||
@@ -281,6 +285,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
await keywordDetector?.["chat.message"]?.(input, output);
|
await keywordDetector?.["chat.message"]?.(input, output);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"experimental.chat.messages.transform": async (
|
||||||
|
input: Record<string, never>,
|
||||||
|
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
|
||||||
|
) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||||
|
},
|
||||||
|
|
||||||
config: async (config) => {
|
config: async (config) => {
|
||||||
const builtinAgents = createBuiltinAgents(
|
const builtinAgents = createBuiltinAgents(
|
||||||
pluginConfig.disabled_agents,
|
pluginConfig.disabled_agents,
|
||||||
|
|||||||
Reference in New Issue
Block a user