| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- import { afterAll, describe, expect, it } from 'bun:test';
- import {
- chmodSync,
- mkdirSync,
- rmSync,
- utimesSync,
- writeFileSync,
- } from 'node:fs';
- import * as os from 'node:os';
- import * as path from 'node:path';
- import { resolveImageRouting } from '../config/constants';
- import { processImageAttachments } from './image-hook';
- import type { MessageWithParts } from './types';
- const TEST_DIR = path.join(os.tmpdir(), `image-hook-test-${process.pid}`);
- const IMG = { type: 'image', url: 'data:image/png;base64,AAAA' };
- function makeTestDir(name: string): { workDir: string; saveDir: string } {
- const workDir = path.join(TEST_DIR, name);
- const saveDir = path.join(workDir, '.opencode', 'images');
- mkdirSync(saveDir, { recursive: true });
- return { workDir, saveDir };
- }
- function makeOldFile(dir: string, name: string): string {
- const filePath = path.join(dir, name);
- writeFileSync(filePath, 'data');
- const past = new Date(Date.now() - 2 * 60 * 60 * 1000);
- utimesSync(filePath, past, past);
- return filePath;
- }
- function makeUserMsg(parts: MessageWithParts['parts']): MessageWithParts {
- return { info: { role: 'user', sessionID: 's1' }, parts };
- }
- function imagePartCount(message: MessageWithParts): number {
- return message.parts.filter((part) => part.type === 'image').length;
- }
- afterAll(() => {
- rmSync(TEST_DIR, { recursive: true, force: true });
- });
- describe('image-hook catch logging', () => {
- it('survives file cleanup failure without throwing', () => {
- const { workDir, saveDir } = makeTestDir('cleanup-fail-1');
- makeOldFile(saveDir, 'old-image.png');
- chmodSync(saveDir, 0o555);
- try {
- expect(() => {
- processImageAttachments({
- messages: [],
- workDir,
- imageRouting: 'auto',
- disabledAgents: new Set<string>(),
- log: () => {},
- });
- }).not.toThrow();
- } finally {
- chmodSync(saveDir, 0o755);
- }
- });
- it('survives subdirectory file cleanup failure without throwing', () => {
- const { workDir, saveDir } = makeTestDir('cleanup-fail-2');
- const sessionDir = path.join(saveDir, 'ses-abc');
- mkdirSync(sessionDir, { recursive: true });
- makeOldFile(sessionDir, 'img.png');
- chmodSync(sessionDir, 0o555);
- try {
- expect(() => {
- processImageAttachments({
- messages: [],
- workDir,
- imageRouting: 'auto',
- disabledAgents: new Set<string>(),
- log: () => {},
- });
- }).not.toThrow();
- } finally {
- chmodSync(sessionDir, 0o755);
- }
- });
- });
- describe('processImageAttachments image routing', () => {
- it('direct mode leaves image parts untouched', () => {
- const message = makeUserMsg([IMG]);
- processImageAttachments({
- messages: [message],
- workDir: path.join(TEST_DIR, 'direct'),
- imageRouting: 'direct',
- disabledAgents: new Set<string>(),
- log: () => {},
- });
- expect(imagePartCount(message)).toBe(1);
- });
- it('auto mode saves image parts and adds an @observer nudge', () => {
- const message = makeUserMsg([IMG]);
- processImageAttachments({
- messages: [message],
- workDir: path.join(TEST_DIR, 'auto'),
- imageRouting: 'auto',
- disabledAgents: new Set<string>(),
- log: () => {},
- });
- expect(imagePartCount(message)).toBe(0);
- const textParts = message.parts.filter((part) => part.type === 'text');
- expect(textParts).toHaveLength(1);
- expect(textParts[0]?.text).toContain('@observer');
- });
- it('resolves omitted image routing to auto and intercepts for Observer', () => {
- const message = makeUserMsg([IMG]);
- processImageAttachments({
- messages: [message],
- workDir: path.join(TEST_DIR, 'omitted-routing'),
- imageRouting: resolveImageRouting(undefined),
- disabledAgents: new Set<string>(),
- log: () => {},
- });
- expect(imagePartCount(message)).toBe(0);
- expect(message.parts.some((part) => part.type === 'text')).toBe(true);
- });
- it('keeps images when auto mode has observer disabled', () => {
- const message = makeUserMsg([IMG]);
- processImageAttachments({
- messages: [message],
- workDir: path.join(TEST_DIR, 'disabled'),
- imageRouting: 'auto',
- disabledAgents: new Set(['observer']),
- log: () => {},
- });
- expect(imagePartCount(message)).toBe(1);
- });
- it('keeps images when auto mode cannot save them', () => {
- const message = makeUserMsg([
- { type: 'image', url: 'https://example.com/image.png' },
- ]);
- const logs: string[] = [];
- processImageAttachments({
- messages: [message],
- workDir: path.join(TEST_DIR, 'unsaved'),
- imageRouting: 'auto',
- disabledAgents: new Set<string>(),
- log: (message) => logs.push(message),
- });
- expect(imagePartCount(message)).toBe(1);
- expect(message.parts).toHaveLength(1);
- expect(logs.some((message) => message.includes('[image-routing]'))).toBe(
- false,
- );
- });
- it('strips only attachments saved successfully', () => {
- const message = makeUserMsg([
- IMG,
- { type: 'image', url: 'https://example.com/image.png' },
- ]);
- processImageAttachments({
- messages: [message],
- workDir: path.join(TEST_DIR, 'mixed'),
- imageRouting: 'auto',
- disabledAgents: new Set<string>(),
- log: () => {},
- });
- expect(imagePartCount(message)).toBe(1);
- expect(message.parts.some((part) => part.type === 'text')).toBe(true);
- });
- it('continues after an earlier message cannot save its images', () => {
- const failed = makeUserMsg([
- { type: 'image', url: 'https://example.com/image.png' },
- ]);
- const saved = makeUserMsg([IMG]);
- processImageAttachments({
- messages: [failed, saved],
- workDir: path.join(TEST_DIR, 'multiple'),
- imageRouting: 'auto',
- disabledAgents: new Set<string>(),
- log: () => {},
- });
- expect(imagePartCount(failed)).toBe(1);
- expect(imagePartCount(saved)).toBe(0);
- });
- it('ignores non-user messages and non-image parts', () => {
- const userText = makeUserMsg([{ type: 'text', text: 'hello' }]);
- const assistant = {
- info: { role: 'assistant', sessionID: 's1' },
- parts: [{ type: 'text', text: 'hi' }],
- } as unknown as MessageWithParts;
- processImageAttachments({
- messages: [userText, assistant],
- workDir: path.join(TEST_DIR, 'non-image'),
- imageRouting: 'auto',
- disabledAgents: new Set<string>(),
- log: () => {},
- });
- expect(userText.parts).toHaveLength(1);
- expect(assistant.parts).toHaveLength(1);
- });
- });
|