Skip to content

Commit efeeac8

Browse files
committed
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)
1 parent 0317102 commit efeeac8

File tree

4 files changed

+105
-0
lines changed

4 files changed

+105
-0
lines changed

src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const HookNameSchema = z.enum([
6262
"agent-usage-reminder",
6363
"non-interactive-env",
6464
"interactive-bash-session",
65+
"empty-message-sanitizer",
6566
])
6667

6768
export const AgentOverrideConfigSchema = z.object({
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Message, Part } from "@opencode-ai/sdk"
2+
3+
const PLACEHOLDER_TEXT = "[user interrupted]"
4+
5+
interface MessageWithParts {
6+
info: Message
7+
parts: Part[]
8+
}
9+
10+
type MessagesTransformHook = {
11+
"experimental.chat.messages.transform"?: (
12+
input: Record<string, never>,
13+
output: { messages: MessageWithParts[] }
14+
) => Promise<void>
15+
}
16+
17+
function hasTextContent(part: Part): boolean {
18+
if (part.type === "text") {
19+
const text = (part as unknown as { text?: string }).text
20+
return Boolean(text && text.trim().length > 0)
21+
}
22+
return false
23+
}
24+
25+
function isToolPart(part: Part): boolean {
26+
const type = part.type as string
27+
return type === "tool" || type === "tool_use" || type === "tool_result"
28+
}
29+
30+
function hasValidContent(parts: Part[]): boolean {
31+
return parts.some((part) => hasTextContent(part) || isToolPart(part))
32+
}
33+
34+
export function createEmptyMessageSanitizerHook(): MessagesTransformHook {
35+
return {
36+
"experimental.chat.messages.transform": async (_input, output) => {
37+
const { messages } = output
38+
39+
for (const message of messages) {
40+
if (message.info.role === "user") continue
41+
42+
const parts = message.parts
43+
44+
if (!hasValidContent(parts) && parts.length > 0) {
45+
let injected = false
46+
47+
for (const part of parts) {
48+
if (part.type === "text") {
49+
const textPart = part as unknown as { text?: string; synthetic?: boolean }
50+
if (!textPart.text || !textPart.text.trim()) {
51+
textPart.text = PLACEHOLDER_TEXT
52+
textPart.synthetic = true
53+
injected = true
54+
break
55+
}
56+
}
57+
}
58+
59+
if (!injected) {
60+
const insertIndex = parts.findIndex((p) => isToolPart(p))
61+
62+
const newPart = {
63+
id: `synthetic_${Date.now()}`,
64+
messageID: message.info.id,
65+
sessionID: (message.info as unknown as { sessionID?: string }).sessionID ?? "",
66+
type: "text" as const,
67+
text: PLACEHOLDER_TEXT,
68+
synthetic: true,
69+
}
70+
71+
if (insertIndex === -1) {
72+
parts.push(newPart as Part)
73+
} else {
74+
parts.splice(insertIndex, 0, newPart as Part)
75+
}
76+
}
77+
}
78+
79+
for (const part of parts) {
80+
if (part.type === "text") {
81+
const textPart = part as unknown as { text?: string; synthetic?: boolean }
82+
if (textPart.text !== undefined && textPart.text.trim() === "") {
83+
textPart.text = PLACEHOLDER_TEXT
84+
textPart.synthetic = true
85+
}
86+
}
87+
}
88+
}
89+
},
90+
}
91+
}

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ export { createAgentUsageReminderHook } from "./agent-usage-reminder";
1919
export { createKeywordDetectorHook } from "./keyword-detector";
2020
export { createNonInteractiveEnvHook } from "./non-interactive-env";
2121
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
22+
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";

src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
createAgentUsageReminderHook,
2121
createNonInteractiveEnvHook,
2222
createInteractiveBashSessionHook,
23+
createEmptyMessageSanitizerHook,
2324
} from "./hooks";
2425
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
2526
import {
@@ -246,6 +247,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
246247
const interactiveBashSession = isHookEnabled("interactive-bash-session")
247248
? createInteractiveBashSessionHook(ctx)
248249
: null;
250+
const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer")
251+
? createEmptyMessageSanitizerHook()
252+
: null;
249253

250254
updateTerminalTitle({ sessionId: "main" });
251255

@@ -281,6 +285,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
281285
await keywordDetector?.["chat.message"]?.(input, output);
282286
},
283287

288+
"experimental.chat.messages.transform": async (
289+
input: Record<string, never>,
290+
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
291+
) => {
292+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
293+
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
294+
},
295+
284296
config: async (config) => {
285297
const builtinAgents = createBuiltinAgents(
286298
pluginConfig.disabled_agents,

0 commit comments

Comments
 (0)