image-hook.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import { afterAll, describe, expect, it } from 'bun:test';
  2. import {
  3. chmodSync,
  4. mkdirSync,
  5. rmSync,
  6. utimesSync,
  7. writeFileSync,
  8. } from 'node:fs';
  9. import * as os from 'node:os';
  10. import * as path from 'node:path';
  11. import { resolveImageRouting } from '../config/constants';
  12. import { processImageAttachments } from './image-hook';
  13. import type { MessageWithParts } from './types';
  14. const TEST_DIR = path.join(os.tmpdir(), `image-hook-test-${process.pid}`);
  15. const IMG = { type: 'image', url: 'data:image/png;base64,AAAA' };
  16. function makeTestDir(name: string): { workDir: string; saveDir: string } {
  17. const workDir = path.join(TEST_DIR, name);
  18. const saveDir = path.join(workDir, '.opencode', 'images');
  19. mkdirSync(saveDir, { recursive: true });
  20. return { workDir, saveDir };
  21. }
  22. function makeOldFile(dir: string, name: string): string {
  23. const filePath = path.join(dir, name);
  24. writeFileSync(filePath, 'data');
  25. const past = new Date(Date.now() - 2 * 60 * 60 * 1000);
  26. utimesSync(filePath, past, past);
  27. return filePath;
  28. }
  29. function makeUserMsg(parts: MessageWithParts['parts']): MessageWithParts {
  30. return { info: { role: 'user', sessionID: 's1' }, parts };
  31. }
  32. function imagePartCount(message: MessageWithParts): number {
  33. return message.parts.filter((part) => part.type === 'image').length;
  34. }
  35. afterAll(() => {
  36. rmSync(TEST_DIR, { recursive: true, force: true });
  37. });
  38. describe('image-hook catch logging', () => {
  39. it('survives file cleanup failure without throwing', () => {
  40. const { workDir, saveDir } = makeTestDir('cleanup-fail-1');
  41. makeOldFile(saveDir, 'old-image.png');
  42. chmodSync(saveDir, 0o555);
  43. try {
  44. expect(() => {
  45. processImageAttachments({
  46. messages: [],
  47. workDir,
  48. imageRouting: 'auto',
  49. disabledAgents: new Set<string>(),
  50. log: () => {},
  51. });
  52. }).not.toThrow();
  53. } finally {
  54. chmodSync(saveDir, 0o755);
  55. }
  56. });
  57. it('survives subdirectory file cleanup failure without throwing', () => {
  58. const { workDir, saveDir } = makeTestDir('cleanup-fail-2');
  59. const sessionDir = path.join(saveDir, 'ses-abc');
  60. mkdirSync(sessionDir, { recursive: true });
  61. makeOldFile(sessionDir, 'img.png');
  62. chmodSync(sessionDir, 0o555);
  63. try {
  64. expect(() => {
  65. processImageAttachments({
  66. messages: [],
  67. workDir,
  68. imageRouting: 'auto',
  69. disabledAgents: new Set<string>(),
  70. log: () => {},
  71. });
  72. }).not.toThrow();
  73. } finally {
  74. chmodSync(sessionDir, 0o755);
  75. }
  76. });
  77. });
  78. describe('processImageAttachments image routing', () => {
  79. it('direct mode leaves image parts untouched', () => {
  80. const message = makeUserMsg([IMG]);
  81. processImageAttachments({
  82. messages: [message],
  83. workDir: path.join(TEST_DIR, 'direct'),
  84. imageRouting: 'direct',
  85. disabledAgents: new Set<string>(),
  86. log: () => {},
  87. });
  88. expect(imagePartCount(message)).toBe(1);
  89. });
  90. it('auto mode saves image parts and adds an @observer nudge', () => {
  91. const message = makeUserMsg([IMG]);
  92. processImageAttachments({
  93. messages: [message],
  94. workDir: path.join(TEST_DIR, 'auto'),
  95. imageRouting: 'auto',
  96. disabledAgents: new Set<string>(),
  97. log: () => {},
  98. });
  99. expect(imagePartCount(message)).toBe(0);
  100. const textParts = message.parts.filter((part) => part.type === 'text');
  101. expect(textParts).toHaveLength(1);
  102. expect(textParts[0]?.text).toContain('@observer');
  103. });
  104. it('resolves omitted image routing to auto and intercepts for Observer', () => {
  105. const message = makeUserMsg([IMG]);
  106. processImageAttachments({
  107. messages: [message],
  108. workDir: path.join(TEST_DIR, 'omitted-routing'),
  109. imageRouting: resolveImageRouting(undefined),
  110. disabledAgents: new Set<string>(),
  111. log: () => {},
  112. });
  113. expect(imagePartCount(message)).toBe(0);
  114. expect(message.parts.some((part) => part.type === 'text')).toBe(true);
  115. });
  116. it('keeps images when auto mode has observer disabled', () => {
  117. const message = makeUserMsg([IMG]);
  118. processImageAttachments({
  119. messages: [message],
  120. workDir: path.join(TEST_DIR, 'disabled'),
  121. imageRouting: 'auto',
  122. disabledAgents: new Set(['observer']),
  123. log: () => {},
  124. });
  125. expect(imagePartCount(message)).toBe(1);
  126. });
  127. it('keeps images when auto mode cannot save them', () => {
  128. const message = makeUserMsg([
  129. { type: 'image', url: 'https://example.com/image.png' },
  130. ]);
  131. const logs: string[] = [];
  132. processImageAttachments({
  133. messages: [message],
  134. workDir: path.join(TEST_DIR, 'unsaved'),
  135. imageRouting: 'auto',
  136. disabledAgents: new Set<string>(),
  137. log: (message) => logs.push(message),
  138. });
  139. expect(imagePartCount(message)).toBe(1);
  140. expect(message.parts).toHaveLength(1);
  141. expect(logs.some((message) => message.includes('[image-routing]'))).toBe(
  142. false,
  143. );
  144. });
  145. it('strips only attachments saved successfully', () => {
  146. const message = makeUserMsg([
  147. IMG,
  148. { type: 'image', url: 'https://example.com/image.png' },
  149. ]);
  150. processImageAttachments({
  151. messages: [message],
  152. workDir: path.join(TEST_DIR, 'mixed'),
  153. imageRouting: 'auto',
  154. disabledAgents: new Set<string>(),
  155. log: () => {},
  156. });
  157. expect(imagePartCount(message)).toBe(1);
  158. expect(message.parts.some((part) => part.type === 'text')).toBe(true);
  159. });
  160. it('continues after an earlier message cannot save its images', () => {
  161. const failed = makeUserMsg([
  162. { type: 'image', url: 'https://example.com/image.png' },
  163. ]);
  164. const saved = makeUserMsg([IMG]);
  165. processImageAttachments({
  166. messages: [failed, saved],
  167. workDir: path.join(TEST_DIR, 'multiple'),
  168. imageRouting: 'auto',
  169. disabledAgents: new Set<string>(),
  170. log: () => {},
  171. });
  172. expect(imagePartCount(failed)).toBe(1);
  173. expect(imagePartCount(saved)).toBe(0);
  174. });
  175. it('ignores non-user messages and non-image parts', () => {
  176. const userText = makeUserMsg([{ type: 'text', text: 'hello' }]);
  177. const assistant = {
  178. info: { role: 'assistant', sessionID: 's1' },
  179. parts: [{ type: 'text', text: 'hi' }],
  180. } as unknown as MessageWithParts;
  181. processImageAttachments({
  182. messages: [userText, assistant],
  183. workDir: path.join(TEST_DIR, 'non-image'),
  184. imageRouting: 'auto',
  185. disabledAgents: new Set<string>(),
  186. log: () => {},
  187. });
  188. expect(userText.parts).toHaveLength(1);
  189. expect(assistant.parts).toHaveLength(1);
  190. });
  191. });