tinyjuice 0.2.1

Pluggable token compression for OpenHuman.
Documentation
diff --git a/app/src/pages/__tests__/Conversations.attachments.test.tsx b/app/src/pages/__tests__/Conversations.attachments.test.tsx
index e24e569ce..8167f088c 100644
--- a/app/src/pages/__tests__/Conversations.attachments.test.tsx
+++ b/app/src/pages/__tests__/Conversations.attachments.test.tsx
@@ -1,100 +1,104 @@
 /**
  * Attachment feature tests for Conversations.tsx — covers the new lines added
  * for multimodal image attachments: handleAttachFiles, error display,
  * attachment-only sends, and user bubble image rendering.
  */
 import { combineReducers, configureStore } from '@reduxjs/toolkit';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
 import { Provider } from 'react-redux';
 import { MemoryRouter } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
 import { SidebarSlotOutlet, SidebarSlotProvider } from '../../components/layout/shell/SidebarSlot';
 import agentProfileReducer from '../../store/agentProfileSlice';
[... 4 context line(s) omitted ...]
 
 // ── Hoisted mock state ──────────────────────────────────────────────────────
 
+const TINY_PNG_DATA_URI = 'data:image/png;base64,iVBORw0KGgo=';
+const originalCreateObjectURL = URL.createObjectURL;
+const originalRevokeObjectURL = URL.revokeObjectURL;
+
 const {
   mockGetThreads,
   mockGetThreadMessages,
[... 74 context line(s) omitted ...]
     persistReaction: vi.fn().mockResolvedValue({}),
   },
 }));
@@ -190,180 +194,200 @@ vi.mock('../../lib/coreState/store', () => ({
 
 // ── Helpers ─────────────────────────────────────────────────────────────────
 
[... 74 context line(s) omitted ...]
 // ── Tests ────────────────────────────────────────────────────────────────────
 
 describe('Conversations — attachment feature', () => {
+  let objectUrlCounter = 0;
+
   beforeEach(() => {
     vi.clearAllMocks();
+    objectUrlCounter = 0;
+    Object.defineProperty(URL, 'createObjectURL', {
+      configurable: true,
+      value: vi.fn(() => `blob:conversation-attachment-${++objectUrlCounter}`),
+    });
+    Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() });
     mockVisionState.vision = true;
     mockGetThreads.mockResolvedValue({ threads: [], count: 0 });
     mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 });
[... 12 context line(s) omitted ...]
     });
   });
 
+  afterEach(() => {
+    cleanup();
+    Object.defineProperty(URL, 'createObjectURL', {
+      configurable: true,
+      value: originalCreateObjectURL,
+    });
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      configurable: true,
+      value: originalRevokeObjectURL,
+    });
+  });
+
   it('renders the attachment button in the composer', async () => {
     await renderWithSelectedThread();
     expect(screen.getByTitle('Attach file')).toBeInTheDocument();
[... 74 context line(s) omitted ...]
       expect(screen.getByText(/8 MB/i)).toBeInTheDocument();
     });
   });
@@ -462,366 +486,368 @@ describe('Conversations — attachment feature', () => {
     });
     // The image is not attached, and the profile is left untouched.
     expect(screen.queryByText('no-vision.png')).not.toBeInTheDocument();
[... 74 context line(s) omitted ...]
 
   it('renders image thumbnails in user message bubble from extraMetadata', async () => {
     const thread = makeThread({ id: 'img-thread', title: 'Img Thread' });
-    const dataUri = 'data:image/png;base64,abc123';
+    const dataUri = TINY_PNG_DATA_URI;
     const message = {
       id: 'msg-1',
       content: 'look at this',
[... 35 context line(s) omitted ...]
     );
 
     await waitFor(() => {
-      const img = document.querySelector(`img[src="${dataUri}"]`);
+      const img = document.querySelector('img[src^="blob:conversation-attachment-"]');
       expect(img).not.toBeNull();
     });
+    expect(URL.createObjectURL).toHaveBeenCalled();
   });
 
   it('renders a document filename chip in the user bubble from attachmentKinds/Names', async () => {
[... 109 context line(s) omitted ...]
     const writeText = vi.fn().mockResolvedValue(undefined);
     Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
     const thread = makeThread({ id: 'legacy-thread', title: 'Legacy Thread' });
-    const dataUri = 'data:image/png;base64,legacy123';
+    const dataUri = TINY_PNG_DATA_URI;
     const message = {
       id: 'msg-legacy-1',
       content: `read this [IMAGE:${dataUri}] and [FILE:data:application/pdf;base64,xyz]`,
[... 36 context line(s) omitted ...]
 
     // The image marker's data URI still renders as an <img> (parsed out for display)...
     await waitFor(() => {
-      const img = document.querySelector(`img[src="${dataUri}"]`);
+      const img = document.querySelector('img[src^="blob:conversation-attachment-"]');
       expect(img).not.toBeNull();
     });
+    expect(URL.createObjectURL).toHaveBeenCalled();
 
     // ...but the raw marker syntax must never leak into the rendered bubble text.
     expect(document.body.textContent).not.toContain('[IMAGE:');
[... 74 context line(s) omitted ...]
     // unchanged must not dispatch an update.
     fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' }));
     const input = await screen.findByRole('textbox', { name: 'Edit thread title' });

[compacted tool output — this is a PARTIAL view; the full original (25120 bytes) is available by calling tokenjuice_retrieve with token "35cd9dc70547ad3520168f43e27af3c1" (marker ⟦tj:35cd9dc70547ad3520168f43e27af3c1⟧)]