diff --git a/src/config/schema.ts b/src/config/schema.ts index 9c0464a..c900a9b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -62,6 +62,7 @@ export const HookNameSchema = z.enum([ "agent-usage-reminder", "non-interactive-env", "interactive-bash-session", + "empty-message-sanitizer", ]) export const AgentOverrideConfigSchema = z.object({ diff --git a/src/hooks/empty-message-sanitizer/index.ts b/src/hooks/empty-message-sanitizer/index.ts new file mode 100644 index 0000000..8c2e13c --- /dev/null +++ b/src/hooks/empty-message-sanitizer/index.ts @@ -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, + output: { messages: MessageWithParts[] } + ) => Promise +} + +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 + } + } + } + } + }, + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 911a12a..7af8102 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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"; diff --git a/src/index.ts b/src/index.ts index c367239..849dd09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import { createAgentUsageReminderHook, createNonInteractiveEnvHook, createInteractiveBashSessionHook, + createEmptyMessageSanitizerHook, } from "./hooks"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; import { @@ -246,6 +247,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const interactiveBashSession = isHookEnabled("interactive-bash-session") ? createInteractiveBashSessionHook(ctx) : null; + const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer") + ? createEmptyMessageSanitizerHook() + : null; updateTerminalTitle({ sessionId: "main" }); @@ -281,6 +285,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await keywordDetector?.["chat.message"]?.(input, output); }, + "experimental.chat.messages.transform": async ( + input: Record, + 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) => { const builtinAgents = createBuiltinAgents( pluginConfig.disabled_agents,