Skip to content
Merged
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
172 changes: 172 additions & 0 deletions apps/mobile/src/components/agents/file-part-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- cohesive unit suite for the file-part cache: capture, overwrite, resolve-failed, and hook-subscription paths share one mock harness */
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React trees under vitest (same pattern as src/components/agents/attachment-preview-strip.mounted.test.tsx) */
import { createElement } from 'react';
import TestRenderer, { act } from 'react-test-renderer';
Expand All @@ -6,8 +7,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
__resetFilePartCacheForTests,
cacheFilePart,
clearFilePartResolveFailed,
getFilePartCacheEntry,
isUsableFilePartUrl,
markFilePartResolveFailed,
overwriteFilePartCacheEntry,
useFilePartCache,
} from '@/components/agents/file-part-cache';

Expand Down Expand Up @@ -188,6 +192,150 @@ describe('cacheFilePart', () => {
expect(fileInstances).toHaveLength(0);
expect(getFilePartCacheEntry('part-1')).toBeUndefined();
});

it('stores a ref-only entry for a cloud-agent attachment file:// URL', () => {
const uuid = '11111111-2222-4333-8444-555555555555';
cacheFilePart('part-attach', {
url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`,
mime: 'text/markdown',
filename: `${uuid}.md`,
});

expect(fileInstances).toHaveLength(0);
expect(getFilePartCacheEntry('part-attach')).toEqual({
mime: 'text/markdown',
filename: `${uuid}.md`,
attachmentRef: { messageUuid: uuid, filename: `${uuid}.md` },
});
expect(getFilePartCacheEntry('part-attach')).not.toHaveProperty('url');
});
});

describe('overwriteFilePartCacheEntry', () => {
const uuid = '11111111-2222-4333-8444-555555555555';

it('replaces url, preserves attachmentRef, and emits', async () => {
cacheFilePart('part-overwrite', {
url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`,
mime: 'text/markdown',
filename: `${uuid}.md`,
});
const renderer = await mountProbe('part-overwrite');

await act(async () => {
await Promise.resolve();
overwriteFilePartCacheEntry('part-overwrite', {
url: 'https://r2.example/signed',
mime: 'text/markdown',
filename: `${uuid}.md`,
});
});

expect(textOf(renderer)).toBe('https://r2.example/signed|ok');
expect(getFilePartCacheEntry('part-overwrite')).toEqual({
url: 'https://r2.example/signed',
mime: 'text/markdown',
filename: `${uuid}.md`,
attachmentRef: { messageUuid: uuid, filename: `${uuid}.md` },
});
renderer.unmount();
});

it('keeps attachmentRef and clears resolveFailed after a failed resolve', () => {
cacheFilePart('part-overwrite-failed', {
url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`,
mime: 'text/markdown',
filename: `${uuid}.md`,
});
markFilePartResolveFailed('part-overwrite-failed');

overwriteFilePartCacheEntry('part-overwrite-failed', {
url: 'https://example.com/fresh.md',
mime: 'text/markdown',
filename: 'fresh.md',
});

expect(getFilePartCacheEntry('part-overwrite-failed')).toEqual({
url: 'https://example.com/fresh.md',
mime: 'text/markdown',
filename: 'fresh.md',
attachmentRef: { messageUuid: uuid, filename: `${uuid}.md` },
});
expect(getFilePartCacheEntry('part-overwrite-failed')).not.toHaveProperty('resolveFailed');
});

it('does not overwrite or emit when the payload URL is unusable', async () => {
cacheFilePart('part-overwrite-unusable', {
url: 'https://example.com/keep.md',
mime: 'text/markdown',
filename: 'keep.md',
});
const before = getFilePartCacheEntry('part-overwrite-unusable');

const onRender = vi.fn<() => void>();
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
await act(async () => {
await Promise.resolve();
ref.current = TestRenderer.create(
createElement(CountingProbe, { partId: 'part-overwrite-unusable', onRender })
);
});
const renderer = ref.current;
if (!renderer) {
throw new Error('renderer was not created');
}
const rendersBefore = onRender.mock.calls.length;
expect(textOf(renderer)).toBe('https://example.com/keep.md');

await act(async () => {
await Promise.resolve();
overwriteFilePartCacheEntry('part-overwrite-unusable', {
url: 'file:///etc/passwd',
mime: 'text/plain',
filename: 'x.txt',
});
});

expect(getFilePartCacheEntry('part-overwrite-unusable')).toBe(before);
expect(textOf(renderer)).toBe('https://example.com/keep.md');
expect(onRender.mock.calls.length).toBe(rendersBefore);

renderer.unmount();
});
});

describe('markFilePartResolveFailed / clearFilePartResolveFailed', () => {
const uuid = '11111111-2222-4333-8444-555555555555';

it('marks on a new identity, clears the key, and emits', async () => {
cacheFilePart('part-fail', {
url: `file:///tmp/attachments/agent-1/user-1/${uuid}/x.md`,
mime: 'text/markdown',
filename: 'x.md',
});
const before = getFilePartCacheEntry('part-fail');
const renderer = await mountProbe('part-fail');

await act(async () => {
await Promise.resolve();
markFilePartResolveFailed('part-fail');
});
const marked = getFilePartCacheEntry('part-fail');
expect(marked?.resolveFailed).toBe(true);
expect(marked).not.toBe(before);
expect(textOf(renderer)).toBe('none|failed');

await act(async () => {
await Promise.resolve();
clearFilePartResolveFailed('part-fail');
});
const cleared = getFilePartCacheEntry('part-fail');
expect(cleared).not.toHaveProperty('resolveFailed');
expect(cleared).not.toBe(marked);
expect(textOf(renderer)).toBe('none|ok');

renderer.unmount();
});
});

describe('isUsableFilePartUrl', () => {
Expand All @@ -209,6 +357,30 @@ function Probe({ partId }: { partId: string }) {
return createElement('Text', null, entry?.url ?? 'none');
}

function EntryProbe({ partId }: { partId: string }) {
const entry = useFilePartCache(partId);
const failed = entry?.resolveFailed === true ? 'failed' : 'ok';
return createElement('Text', null, `${entry?.url ?? 'none'}|${failed}`);
}

function CountingProbe({ partId, onRender }: { partId: string; onRender: () => void }) {
const entry = useFilePartCache(partId);
onRender();
return createElement('Text', null, entry?.url ?? 'none');
}

async function mountProbe(partId: string): Promise<TestRenderer.ReactTestRenderer> {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
await act(async () => {
await Promise.resolve();
ref.current = TestRenderer.create(createElement(EntryProbe, { partId }));
});
if (!ref.current) {
throw new Error('renderer was not created');
}
return ref.current;
}

function textOf(renderer: TestRenderer.ReactTestRenderer): string | undefined {
const node = renderer.root.find(n => typeof n.type === 'string' && (n.type as string) === 'Text');
return node.props.children as string | undefined;
Expand Down
82 changes: 70 additions & 12 deletions apps/mobile/src/components/agents/file-part-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useSyncExternalStore } from 'react';

import { getSafeCacheFilename } from '@/lib/share-remote-file';

import { type CloudAgentAttachmentRef, parseCloudAgentAttachmentUrl } from './file-part-preview';
import { stripDataUrlBase64Prefix } from './tool-card-image-cache';

const CACHE_DIR_NAME = 'session-file-parts';
Expand All @@ -11,19 +12,20 @@ const CACHE_DIR_NAME = 'session-file-parts';
* small `file://` URI; `http(s)` URLs are stored as-is. No bytes are
* downloaded. */
export type FilePartCacheEntry = {
url: string;
// resolved usable URL; ABSENT on ref-only entries
url?: string;
mime: string;
filename?: string;
attachmentRef?: CloudAgentAttachmentRef;
// last on-demand presign failed; cleared on success/retry
resolveFailed?: boolean;
};

/** Reactive map of partId → captured FilePart entry. First write wins. */
const entriesByPartId = new Map<string, FilePartCacheEntry>();
const listeners = new Set<() => void>();
/** Bumped on every map mutation so useSyncExternalStore sees a new snapshot. */
let entriesVersion = 0;

function emitChange(): void {
entriesVersion += 1;
for (const listener of listeners) {
listener();
}
Expand All @@ -36,10 +38,6 @@ function subscribe(listener: () => void): () => void {
};
}

function getVersionSnapshot(): number {
return entriesVersion;
}

/**
* Resolve the URL to store for a captured FilePart. A `data:` URL is written
* to disk and stored as a `file://` URI; an `http(s)` URL is stored as-is.
Expand Down Expand Up @@ -77,7 +75,9 @@ function resolveCacheUrl(
/**
* Record a captured FilePart URL. A `data:` URL is written to disk and stored
* as a `file://` URI; an `http(s)` URL is stored as-is. Never downloads bytes.
* First write wins: a later call for the same `partId` is a no-op.
* A cloud-agent sandbox `file://` attachment URL stores a ref-only entry (no
* `url` key) for later on-demand presigning. First write wins: a later call
* for the same `partId` is a no-op.
*/
export function cacheFilePart(
partId: string,
Expand All @@ -86,6 +86,37 @@ export function cacheFilePart(
if (entriesByPartId.has(partId)) {
return;
}
const ref = parseCloudAgentAttachmentUrl(payload.url);
if (ref) {
entriesByPartId.set(partId, {
mime: payload.mime,
...(payload.filename ? { filename: payload.filename } : {}),
attachmentRef: ref,
});
emitChange();
return;
}
const url = resolveCacheUrl(partId, payload);
if (url === undefined) {
return;
}
entriesByPartId.set(partId, {
url,
mime: payload.mime,
...(payload.filename ? { filename: payload.filename } : {}),
});
emitChange();
}

/**
* Replace a cached entry with a freshly resolved URL (e.g. a re-presigned
* download URL). Preserves any attachment reference and clears the failed
* mark. If `resolveCacheUrl` returns `undefined`, the entry is not written.
*/
export function overwriteFilePartCacheEntry(
partId: string,
payload: Readonly<{ url: string; mime: string; filename?: string }>
): void {
const url = resolveCacheUrl(partId, payload);
if (url === undefined) {
return;
Expand All @@ -94,22 +125,49 @@ export function cacheFilePart(
url,
mime: payload.mime,
...(payload.filename ? { filename: payload.filename } : {}),
attachmentRef: entriesByPartId.get(partId)?.attachmentRef,
});
emitChange();
}

/** Mark an existing entry's last on-demand presign as failed. */
export function markFilePartResolveFailed(partId: string): void {
const entry = entriesByPartId.get(partId);
if (!entry) {
return;
}
entriesByPartId.set(partId, { ...entry, resolveFailed: true });
emitChange();
}

/** Clear the failed mark from an existing entry. */
export function clearFilePartResolveFailed(partId: string): void {
const entry = entriesByPartId.get(partId);
if (!entry) {
return;
}
const next = { ...entry };
delete next.resolveFailed;
entriesByPartId.set(partId, next);
emitChange();
}

/** Synchronous lookup used by tests. */
export function getFilePartCacheEntry(partId: string): FilePartCacheEntry | undefined {
return entriesByPartId.get(partId);
}

/**
* Reactive lookup of a captured FilePart entry. Returns `undefined` until a
* write is recorded for `partId`.
* write is recorded for `partId`. The snapshot is the entry object itself so
* a post-mount write (e.g. an on-demand presign) re-renders every subscriber.
*/
export function useFilePartCache(partId: string): FilePartCacheEntry | undefined {
useSyncExternalStore(subscribe, getVersionSnapshot, getVersionSnapshot);
return entriesByPartId.get(partId);
return useSyncExternalStore(
subscribe,
() => entriesByPartId.get(partId),
() => entriesByPartId.get(partId)
);
}

/** True only for `http:`, `https:`, and `data:` URLs. */
Expand Down
45 changes: 45 additions & 0 deletions apps/mobile/src/components/agents/file-part-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,53 @@ import {
getFilePartAccessibilityLabel,
getFilePartKind,
isMarkdownFilePart,
parseCloudAgentAttachmentUrl,
} from './file-part-preview';

describe('parseCloudAgentAttachmentUrl', () => {
const UUID = '11111111-2222-4333-8444-555555555555';

it('parses a sandbox URL for .md, .mdx, and .png filenames', () => {
for (const filename of ['notes.md', 'docs.mdx', 'shot.png']) {
expect(
parseCloudAgentAttachmentUrl(`file:///tmp/attachments/agent-1/user-1/${UUID}/${filename}`)
).toEqual({ messageUuid: UUID, filename });
}
});

it('returns undefined for a non-attachment file:// URL', () => {
expect(parseCloudAgentAttachmentUrl('file:///etc/passwd')).toBeUndefined();
});

it('returns undefined for an empty string', () => {
expect(parseCloudAgentAttachmentUrl('')).toBeUndefined();
});

it('returns undefined for http(s) and data: URLs', () => {
expect(parseCloudAgentAttachmentUrl('http://example.com/a.md')).toBeUndefined();
expect(parseCloudAgentAttachmentUrl('https://example.com/a.md')).toBeUndefined();
expect(parseCloudAgentAttachmentUrl('data:text/markdown;base64,QUJD')).toBeUndefined();
});

it('returns undefined for extra path segments after the uuid', () => {
expect(
parseCloudAgentAttachmentUrl(`file:///tmp/attachments/agent-1/user-1/${UUID}/notes.md/extra`)
).toBeUndefined();
});

it('returns undefined for a trailing slash', () => {
expect(
parseCloudAgentAttachmentUrl(`file:///tmp/attachments/agent-1/user-1/${UUID}/notes.md/`)
).toBeUndefined();
});

it('returns undefined when the uuid segment is not a uuid', () => {
expect(
parseCloudAgentAttachmentUrl('file:///tmp/attachments/agent-1/user-1/not-a-uuid/notes.md')
).toBeUndefined();
});
});

describe('isMarkdownFilePart', () => {
it('is true for .md and .mdx filenames', () => {
expect(isMarkdownFilePart('README.md')).toBe(true);
Expand Down
Loading