Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/.monicon/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -1029,5 +1029,20 @@ module.exports = {
"svg": "<svg viewBox=\"0 0 58 44\" width=\"1em\" height=\"1em\" ><path fill=\"currentColor\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M0 44V0H14.7304V13.4775L21.2348 0H35.9652V13.4775L42.4696 0H57.2V44H42.4696V30.5225L35.9652 44H21.2348V30.5225L14.7304 44H0ZM12.4348 2.29565H2.29565V39.2432L12.4348 18.2342V2.29565ZM44.7652 41.7043H54.9044V4.75676L44.7652 25.7658V41.7043ZM34.5241 41.7043L53.5431 2.29565H43.9107L24.8917 41.7043H34.5241ZM32.3083 2.29565H22.6759L3.65691 41.7043H13.2893L32.3083 2.29565ZM33.6696 4.75676L23.5304 25.7658V39.2432L33.6696 18.2342V4.75676Z\"/></svg>",
"width": 16,
"height": 16
},
"ri:google-fill": {
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M3.064 7.51A10 10 0 0 1 12 2c2.695 0 4.959.991 6.69 2.605l-2.867 2.868C14.786 6.482 13.468 5.977 12 5.977c-2.605 0-4.81 1.76-5.595 4.123c-.2.6-.314 1.24-.314 1.9s.114 1.3.314 1.9c.786 2.364 2.99 4.123 5.595 4.123c1.345 0 2.49-.355 3.386-.955a4.6 4.6 0 0 0 1.996-3.018H12v-3.868h9.418c.118.654.182 1.336.182 2.045c0 3.046-1.09 5.61-2.982 7.35C16.964 21.105 14.7 22 12 22A9.996 9.996 0 0 1 2 12c0-1.614.386-3.14 1.064-4.49\"/></svg>",
"width": "1em",
"height": "1em"
},
"mdi:image-outline": {
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-5.04 9.29l-2.75 3.54l-1.96-2.36L6.5 17h11z\"/></svg>",
"width": "1em",
"height": "1em"
},
"mdi:cloud-off-outline": {
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" height=\"1em\" viewBox=\"0 0 24 24\"><path fill=\"currentColor\" d=\"M19.8 22.6L17.15 20H6.5q-2.3 0-3.9-1.6T1 14.5q0-1.92 1.19-3.42q1.19-1.51 3.06-1.93q.08-.2.15-.39q.1-.19.15-.41L1.4 4.2l1.4-1.4l18.4 18.4M6.5 18h8.65L7.1 9.95q-.05.28-.07.55q-.03.23-.03.5h-.5q-1.45 0-2.47 1.03Q3 13.05 3 14.5T4.03 17q1.02 1 2.47 1m15.1.75l-1.45-1.4q.43-.35.64-.81T21 15.5q0-1.05-.73-1.77q-.72-.73-1.77-.73H17v-2q0-2.07-1.46-3.54Q14.08 6 12 6q-.67 0-1.3.16q-.63.17-1.2.52L8.05 5.23q.88-.6 1.86-.92Q10.9 4 12 4q2.93 0 4.96 2.04Q19 8.07 19 11q1.73.2 2.86 1.5q1.14 1.28 1.14 3q0 1-.37 1.81q-.38.84-1.03 1.44m-6.77-6.72\"/></svg>",
"width": "1em",
"height": "1em"
}
};
1,694 changes: 1,694 additions & 0 deletions app/__tests__/__snapshots__/persistSchemaDrift.test.ts.snap.node

Large diffs are not rendered by default.

207 changes: 207 additions & 0 deletions app/__tests__/aiSendAssembly.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* Pins the multimodal request-assembly contract shared by `useAiSend`'s
* send and retry flows (`assembleApiMessages` is the ONE assembly seam, so
* testing it pins both), the routstrStore persistence tolerance for the
* new attachment/lineup fields, and the vision-aware candidate filter's
* building blocks in `format.ts`.
*/
import {
measureMessageContent,
type RoutstrChatMessage,
type RoutstrModel,
} from '@/shared/lib/routstr/api';
import type { ChatAttachment, RoutstrMessage } from '@/shared/stores/profile/routstrStore';
import {
MAX_INLINE_IMAGES,
MAX_INLINE_IMAGE_TURNS,
assembleApiMessages,
stripImageParts,
} from '@/features/ai/lib/assembleApiMessages';
import {
entryForSlot,
estimateTurnCostSatsFromPricing,
resolveCandidateEntries,
resolveSelectedEntry,
} from '@/features/ai/lib/format';
import { deriveLineup } from '@/shared/lib/routstr/lineup';
import fixture from './fixtures/routstr-models.fixture.json';

const att = (n: number): ChatAttachment => ({
localUri: `file:///photos/img-${n}.jpg`,
mimeType: 'image/jpeg',
width: 100,
height: 100,
});

const msg = (
id: string,
role: 'user' | 'assistant',
content: string,
attachments?: ChatAttachment[]
): RoutstrMessage => ({ id, role, content, timestamp: 1, attachments });

/** Encoder stub: data-URL derived from the URI; records call counts. */
function makeEncoder(missing: Set<string> = new Set()) {
const calls = new Map<string, number>();
const encode = async (attachment: ChatAttachment) => {
calls.set(attachment.localUri, (calls.get(attachment.localUri) ?? 0) + 1);
if (missing.has(attachment.localUri)) return null;
return `data:image/jpeg;base64,${attachment.localUri}`;
};
return { encode, calls };
}

const imageParts = (m: RoutstrChatMessage): number =>
typeof m.content === 'string' ? 0 : m.content.filter((p) => p.type === 'image_url').length;

describe('assembleApiMessages', () => {
it('keeps text-only turns as plain string content (pre-image wire shape unchanged)', async () => {
const { encode } = makeEncoder();
const { messages, imageCount } = await assembleApiMessages(
[msg('u1', 'user', 'hi'), msg('a1', 'assistant', 'hello'), msg('u2', 'user', 'ok')],
encode
);
expect(imageCount).toBe(0);
expect(messages).toEqual([
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: 'hello' },
{ role: 'user', content: 'ok' },
]);
});

it('expands attachment turns into text + image_url content parts', async () => {
const { encode } = makeEncoder();
const { messages, imageCount } = await assembleApiMessages(
[msg('u1', 'user', 'what is this?', [att(1), att(2)])],
encode
);
expect(imageCount).toBe(2);
expect(messages[0].content).toEqual([
{ type: 'text', text: 'what is this?' },
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${att(1).localUri}` } },
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${att(2).localUri}` } },
]);
});

it(`inlines images only from the newest ${MAX_INLINE_IMAGE_TURNS} attachment turns; older image turns assemble text-only`, async () => {
const { encode, calls } = makeEncoder();
const path = [
msg('u1', 'user', 'first', [att(1)]),
msg('a1', 'assistant', 'r1'),
msg('u2', 'user', 'second', [att(2)]),
msg('a2', 'assistant', 'r2'),
msg('u3', 'user', 'third', [att(3)]),
msg('a3', 'assistant', 'r3'),
];
const { messages, imageCount } = await assembleApiMessages(path, encode);
expect(imageCount).toBe(2);
expect(messages[0]).toEqual({ role: 'user', content: 'first' }); // outside the window
expect(imageParts(messages[2])).toBe(1);
expect(imageParts(messages[4])).toBe(1);
// The windowed-out attachment is never even read from disk.
expect(calls.has(att(1).localUri)).toBe(false);
});

it(`caps inline images at ${MAX_INLINE_IMAGES} per request, newest turns first`, async () => {
const { encode } = makeEncoder();
const path = [
msg('u1', 'user', 'many', [att(1), att(2), att(3)]),
msg('a1', 'assistant', 'r'),
msg('u2', 'user', 'more', [att(4), att(5), att(6)]),
];
const { messages, imageCount } = await assembleApiMessages(path, encode);
expect(imageCount).toBe(MAX_INLINE_IMAGES);
expect(imageParts(messages[2])).toBe(3); // newest turn gets its full set
expect(imageParts(messages[0])).toBe(1); // older turn gets the remainder
});

it('degrades a missing/expired local URI to text-only with the other images intact', async () => {
const { encode } = makeEncoder(new Set([att(1).localUri]));
const { messages, imageCount } = await assembleApiMessages(
[msg('u1', 'user', 'broken + ok', [att(1), att(2)])],
encode
);
expect(imageCount).toBe(1);
expect(imageParts(messages[0])).toBe(1);
// The text part survives regardless.
expect((messages[0].content as { type: string }[])[0]).toEqual({
type: 'text',
text: 'broken + ok',
});
});

it('send/retry parity is structural: identical path in → identical wire messages out', async () => {
const path = [msg('u1', 'user', 'q', [att(1)]), msg('a1', 'assistant', 'r')];
const a = await assembleApiMessages(path, makeEncoder().encode);
const b = await assembleApiMessages(path, makeEncoder().encode);
expect(a).toEqual(b);
});

it('stripImageParts degrades a multimodal request to plain text (no-vision-candidate fallback)', async () => {
const { encode } = makeEncoder();
const { messages } = await assembleApiMessages(
[msg('u1', 'user', 'look', [att(1), att(2)]), msg('a1', 'assistant', 'reply')],
encode
);
const stripped = stripImageParts(messages);
expect(stripped).toEqual([
{ role: 'user', content: 'look' },
{ role: 'assistant', content: 'reply' },
]);
expect(measureMessageContent(stripped).imageParts).toBe(0);
});

it('measureMessageContent counts text chars + image parts without serialising payloads', async () => {
const { encode } = makeEncoder();
const { messages } = await assembleApiMessages(
[msg('u1', 'user', '12345', [att(1)]), msg('a1', 'assistant', '678')],
encode
);
expect(measureMessageContent(messages)).toEqual({ textChars: 8, imageParts: 1 });
});
});

describe('vision-aware candidate resolution (format.ts over the derived lineup)', () => {
const { lineup } = deriveLineup(fixture.data as unknown as RoutstrModel[]);

it('resolves a (provider, tier) slot and returns null only when no lineup exists', () => {
expect(resolveSelectedEntry('openai', 'auto', 1_000_000, lineup)?.modelId).toBe('gpt-5.5');
expect(resolveSelectedEntry('openai', 'auto', 1_000_000, null)).toBeNull();
});

it('falls back across providers in the same tier before other tiers', () => {
const chain = resolveCandidateEntries('claude', 'max', lineup);
expect(chain[0]?.modelId).toBe(entryForSlot(lineup, 'claude', 'max')?.modelId);
// Next candidates are the other providers' max cells, in provider order.
expect(chain[1]?.modelId).toBe(entryForSlot(lineup, 'openai', 'max')?.modelId);
expect(chain.length).toBe(12); // every filled cell exactly once
expect(new Set(chain.map((e) => e.modelId)).size).toBe(12);
});

it('prefers the first affordable candidate, else the primary at any cost', () => {
// Balance below every max_cost → primary comes back unaffordable-first.
const broke = resolveSelectedEntry('claude', 'max', 0, lineup);
expect(broke?.modelId).toBe(entryForSlot(lineup, 'claude', 'max')?.modelId);
// A balance that can only afford the cheapest cells skips to them.
const cheap = resolveSelectedEntry('openai', 'max', 700, lineup);
expect(cheap).not.toBeNull();
expect(cheap!.satsPricing.max_cost!).toBeLessThanOrEqual(700);
});

it('every selected entry carries visionInput so the send path can filter a chain with images', () => {
const chain = resolveCandidateEntries('google', 'max', lineup);
expect(chain.every((e) => typeof e.visionInput === 'boolean')).toBe(true);
expect(chain.filter((e) => e.visionInput).length).toBeGreaterThan(0);
});

it('adds per-image fees into the turn estimate (Gemini undercount fix)', () => {
const gemini = entryForSlot(lineup, 'google', 'max')!;
const base = estimateTurnCostSatsFromPricing(gemini.satsPricing);
const withImages = estimateTurnCostSatsFromPricing(gemini.satsPricing, 2);
expect(base).not.toBeNull();
expect(withImages!).toBeCloseTo(base! + gemini.satsPricing.image! * 2, 6);
// Models without an image fee are unchanged.
const noFee = estimateTurnCostSatsFromPricing({ ...gemini.satsPricing, image: null }, 2);
expect(noFee).toBeCloseTo(base!, 6);
});
});
Loading
Loading