Browse Source

Guard OpenCode message transforms

Zaradacht Taifour 1 month ago
parent
commit
1d4b110a28

+ 18 - 0
src/hooks/filter-available-skills/index.test.ts

@@ -47,6 +47,24 @@ describe('filterAvailableSkillsText', () => {
 });
 
 describe('createFilterAvailableSkillsHook', () => {
+  test('ignores messages without OpenCode info or parts', async () => {
+    const hook = createFilterAvailableSkillsHook(mockCtx, {});
+    const output = {
+      messages: [
+        {},
+        { info: { role: 'assistant' } },
+        {
+          info: { role: 'system' },
+          parts: [{ type: 'text', text: availableSkillsBlock('skill1') }],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, output as never);
+
+    expect(output.messages[2].parts[0].text).toContain('<name>skill1</name>');
+  });
+
   test('filters system prompt skill blocks for explicit agent skills', async () => {
     const config: PluginConfig = {
       agents: {

+ 7 - 3
src/hooks/filter-available-skills/index.ts

@@ -6,7 +6,11 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import { getSkillPermissionsForAgent } from '../../cli/skills';
 import { getAgentOverride, type PluginConfig } from '../../config';
-import type { MessageWithParts } from '../types';
+import {
+  isMessageWithParts,
+  isUserMessageWithParts,
+  type MessageWithParts,
+} from '../types';
 
 const AVAILABLE_SKILLS_BLOCK_REGEX =
   /<available_skills>\s*([\s\S]*?)\s*<\/available_skills>/g;
@@ -22,7 +26,7 @@ interface SkillEntry {
 function getCurrentAgent(messages: MessageWithParts[]): string {
   for (let index = messages.length - 1; index >= 0; index -= 1) {
     const message = messages[index];
-    if (message.info.role === 'user') {
+    if (isUserMessageWithParts(message)) {
       return message.info.agent ?? 'orchestrator';
     }
   }
@@ -115,7 +119,7 @@ export function createFilterAvailableSkillsHook(
       _input: Record<string, never>,
       output: { messages: MessageWithParts[] },
     ): Promise<void> => {
-      const { messages } = output;
+      const messages = output.messages.filter(isMessageWithParts);
       if (messages.length === 0) {
         return;
       }

+ 2 - 2
src/hooks/image-hook.ts

@@ -9,7 +9,7 @@ import {
   writeFileSync,
 } from 'node:fs';
 import { basename, extname, join } from 'node:path';
-import type { MessageWithParts } from './types';
+import { isUserMessageWithParts, type MessageWithParts } from './types';
 
 // Debounce: only run cleanup every 10 minutes per directory
 const lastCleanupByDir = new Map<string, number>();
@@ -170,7 +170,7 @@ export function processImageAttachments(args: {
   }> = [];
 
   for (const msg of messages) {
-    if (msg.info.role !== 'user') continue;
+    if (!isUserMessageWithParts(msg)) continue;
     const imageParts = msg.parts.filter(isImagePart);
     if (imageParts.length > 0) {
       messagesWithImages.push({ msg, imageParts });

+ 19 - 0
src/hooks/task-session-manager/index.test.ts

@@ -43,6 +43,25 @@ function createMessages(sessionID: string, text = 'user message') {
 }
 
 describe('task-session-manager hook', () => {
+  test('ignores messages without OpenCode info or parts', async () => {
+    const { hook } = createHook();
+    const messages = {
+      messages: [
+        {},
+        { info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' } },
+        { parts: [{ type: 'text', text: 'missing info' }] },
+        {
+          info: { role: 'assistant' },
+          parts: [{ type: 'text', text: 'assistant response' }],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages as never);
+
+    expect(messages.messages).toHaveLength(4);
+  });
+
   test('stores background task launches in job board prompt context', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });

+ 7 - 3
src/hooks/task-session-manager/index.ts

@@ -13,7 +13,11 @@ import {
 import { isRecord as isObjectRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 import { isRateLimitError } from '../foreground-fallback/index';
-import type { MessagePart, MessageWithParts } from '../types';
+import {
+  isUserMessageWithParts,
+  type MessagePart,
+  type MessageWithParts,
+} from '../types';
 
 interface TaskArgs {
   description?: unknown;
@@ -642,7 +646,7 @@ export function createTaskSessionManagerHook(
       output: { messages: MessageWithParts[] },
     ): Promise<void> => {
       for (const [messageIndex, message] of output.messages.entries()) {
-        if (message.info.role !== 'user') continue;
+        if (!isUserMessageWithParts(message)) continue;
         if (message.info.agent && message.info.agent !== 'orchestrator') {
           continue;
         }
@@ -660,7 +664,7 @@ export function createTaskSessionManagerHook(
 
       for (let i = output.messages.length - 1; i >= 0; i -= 1) {
         const message = output.messages[i];
-        if (message.info.role !== 'user') continue;
+        if (!isUserMessageWithParts(message)) continue;
         if (message.info.agent && message.info.agent !== 'orchestrator') return;
         if (
           !message.info.sessionID ||

+ 20 - 0
src/hooks/types.ts

@@ -24,3 +24,23 @@ export type MessageWithParts = {
   info: MessageInfo;
   parts: MessagePart[];
 };
+
+export function isMessageWithParts(message: unknown): message is MessageWithParts {
+  if (!message || typeof message !== 'object') {
+    return false;
+  }
+
+  const candidate = message as Partial<MessageWithParts>;
+  return (
+    !!candidate.info &&
+    typeof candidate.info === 'object' &&
+    typeof candidate.info.role === 'string' &&
+    Array.isArray(candidate.parts)
+  );
+}
+
+export function isUserMessageWithParts(
+  message: unknown,
+): message is MessageWithParts {
+  return isMessageWithParts(message) && message.info.role === 'user';
+}

+ 13 - 8
src/index.ts

@@ -32,7 +32,11 @@ import {
   ForegroundFallbackManager,
 } from './hooks';
 import { processImageAttachments } from './hooks/image-hook';
-import type { MessageWithParts } from './hooks/types';
+import {
+  isMessageWithParts,
+  isUserMessageWithParts,
+  type MessageWithParts,
+} from './hooks/types';
 import { createInterviewManager } from './interview';
 import { createBuiltinMcps } from './mcp';
 import {
@@ -1033,10 +1037,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       input: Record<string, never>,
       output: { messages: unknown[] },
     ): Promise<void> => {
-      const typedOutput = output as { messages: MessageWithParts[] };
+      const typedOutput = output as { messages: unknown[] };
+      const messages = typedOutput.messages.filter(isMessageWithParts);
 
-      for (const message of typedOutput.messages) {
-        if (message.info.role !== 'user') {
+      for (const message of messages) {
+        if (!isUserMessageWithParts(message)) {
           continue;
         }
         for (const part of message.parts) {
@@ -1053,7 +1058,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // image bytes with a text nudge so the orchestrator delegates to
       // @observer instead.
       processImageAttachments({
-        messages: typedOutput.messages,
+        messages,
         workDir: ctx.directory,
         disabledAgents,
         log,
@@ -1061,15 +1066,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
       await taskSessionManagerHook['experimental.chat.messages.transform'](
         input,
-        typedOutput,
+        { messages },
       );
       await phaseReminderHook['experimental.chat.messages.transform'](
         input,
-        typedOutput,
+        { messages },
       );
       await filterAvailableSkillsHook['experimental.chat.messages.transform'](
         input,
-        typedOutput,
+        { messages },
       );
     },