diff --git a/apps/mobile/src/components/agents/file-part-cache.test.ts b/apps/mobile/src/components/agents/file-part-cache.test.ts index 8d27d4feea..6d8ab386b6 100644 --- a/apps/mobile/src/components/agents/file-part-cache.test.ts +++ b/apps/mobile/src/components/agents/file-part-cache.test.ts @@ -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'; @@ -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'; @@ -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', () => { @@ -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 { + 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; diff --git a/apps/mobile/src/components/agents/file-part-cache.ts b/apps/mobile/src/components/agents/file-part-cache.ts index 6293bc5b83..ce57d64df1 100644 --- a/apps/mobile/src/components/agents/file-part-cache.ts +++ b/apps/mobile/src/components/agents/file-part-cache.ts @@ -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'; @@ -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(); 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(); } @@ -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. @@ -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, @@ -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; @@ -94,10 +125,33 @@ 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); @@ -105,11 +159,15 @@ export function getFilePartCacheEntry(partId: string): FilePartCacheEntry | unde /** * 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. */ diff --git a/apps/mobile/src/components/agents/file-part-preview.test.ts b/apps/mobile/src/components/agents/file-part-preview.test.ts index b91a12e7fb..9964978526 100644 --- a/apps/mobile/src/components/agents/file-part-preview.test.ts +++ b/apps/mobile/src/components/agents/file-part-preview.test.ts @@ -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); diff --git a/apps/mobile/src/components/agents/file-part-preview.ts b/apps/mobile/src/components/agents/file-part-preview.ts index e4b0da6191..4d4a5fc592 100644 --- a/apps/mobile/src/components/agents/file-part-preview.ts +++ b/apps/mobile/src/components/agents/file-part-preview.ts @@ -1,5 +1,28 @@ import { isMarkdownPath } from './read-tool-markdown'; +/** A cloud-agent attachment reference parsed from the wrapper's sandbox URL. */ +export type CloudAgentAttachmentRef = { messageUuid: string; filename: string }; + +// Persisted history and live events carry the wrapper's sandbox URL +// `file:///tmp/attachments////` +// (`services/cloud-agent-next/src/utils/attachment-download.ts`). +// Removal condition: none — stored messages keep this form permanently. +const CLOUD_AGENT_ATTACHMENT_URL = + /^file:\/\/\/tmp\/attachments\/[^/]+\/[^/]+\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\/([^/]+)$/; + +export function parseCloudAgentAttachmentUrl(url: string): CloudAgentAttachmentRef | undefined { + const match = CLOUD_AGENT_ATTACHMENT_URL.exec(url); + if (!match) { + return undefined; + } + const messageUuid = match[1]; + const filename = match[2]; + if (messageUuid === undefined || filename === undefined) { + return undefined; + } + return { messageUuid, filename }; +} + export type FilePartKind = 'image' | 'markdown' | 'other'; export function isMarkdownFilePart(filename: string | undefined): boolean { diff --git a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx index 9ef5ef3fed..c2abc947ad 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.mounted.test.tsx @@ -6,7 +6,12 @@ import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FilePartRenderer } from './file-part-renderer'; -import { __resetFilePartCacheForTests, cacheFilePart } from './file-part-cache'; +import { + __resetFilePartCacheForTests, + cacheFilePart, + overwriteFilePartCacheEntry, +} from './file-part-cache'; +import { __resetFilePartUrlResolverForTests } from './file-part-url-resolver'; type FileInstance = { uri: string; @@ -84,6 +89,16 @@ const toastMock = vi.hoisted(() => ({ vi.mock('sonner-native', () => ({ toast: toastMock })); +const getAttachmentDownloadUrlMutate = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/trpc', () => ({ + trpcClient: { + cloudAgentNext: { + getAttachmentDownloadUrl: { mutate: getAttachmentDownloadUrlMutate }, + }, + }, +})); + vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', Modal: 'Modal', @@ -122,6 +137,13 @@ beforeEach(() => { vi.clearAllMocks(); fileInstances.length = 0; __resetFilePartCacheForTests(); + __resetFilePartUrlResolverForTests(); + getAttachmentDownloadUrlMutate.mockReset(); + getAttachmentDownloadUrlMutate.mockResolvedValue({ + signedUrl: 'https://r2.example/signed', + key: 'k', + expiresAt: '2026-01-01T00:00:00Z', + }); expoFileSystemMock.fileText.mockReset(); shareRemoteFileMock.getSafeCacheFilename.mockImplementation( ({ id, filename }: { id: string; filename: string }) => `${id}-${filename}` @@ -594,4 +616,395 @@ describe('FilePartRenderer mounted', () => { await unmount(renderer); }); + + it('presigns a markdown attachment and previews its text', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '11111111-1111-4111-8111-111111111111'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate).toHaveBeenCalledWith({ + messageUuid: uuid, + filename: `${uuid}.md`, + }); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + await flushAsync(); + + const markdown = findByType(root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + expect(toastMock.error).not.toHaveBeenCalledWith('Preview unavailable'); + + await unmount(renderer); + }); + + it('opens the markdown modal after a tap during the presign resolves', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '33333333-3333-4333-8333-333333333333'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + const presignHolder: { + resolve?: (value: { signedUrl: string; key: string; expiresAt: string }) => void; + } = {}; + getAttachmentDownloadUrlMutate.mockReturnValueOnce( + new Promise(resolve => { + presignHolder.resolve = resolve; + }) + ); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + // The presign is still in flight, so the chip is busy. A tap during that + // window must open the modal once the URL lands, without a second tap. + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + expect(findByType(root, 'Modal')).toHaveLength(0); + + await act(async () => { + presignHolder.resolve?.({ + signedUrl: 'https://r2.example/signed', + key: 'k', + expiresAt: '2026-01-01T00:00:00Z', + }); + await Promise.resolve(); + }); + await flushAsync(); + + const markdown = findByType(root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + + await unmount(renderer); + }); + + it('shows a retry toast when the presign fails, then opens after a successful retry', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '22222222-2222-4222-8222-222222222222'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + getAttachmentDownloadUrlMutate.mockRejectedValueOnce(new Error('presign failed')); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + + expect(toastMock.error).toHaveBeenCalledWith('Could not load this file. Try again.'); + expect(findByType(root, 'Modal')).toHaveLength(0); + + await flushAsync(); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + await flushAsync(); + + const markdown = findByType(root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + + await unmount(renderer); + }); + + it('presigns an image attachment and renders the inline image', async () => { + const uuid = '33333333-3333-4333-8333-333333333333'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.png`, + mime: 'image/png', + filename: `${uuid}.png`, + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: `${uuid}.png`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + const image = findByType(root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + expect(image.props.source).toEqual({ uri: 'https://r2.example/signed' }); + + await press(first(pressableByLabel(root, `Open ${uuid}.png full screen`))); + + const viewers = findByType(root, 'ImageViewerModal'); + expect(viewers).toHaveLength(1); + expect(viewers[0]?.props).toMatchObject({ visible: true, uri: 'https://r2.example/signed' }); + + await unmount(renderer); + }); + + it('reuses the cached presigned URL after an unmount and remount', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '77777777-7777-4777-8777-777777777777'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate).toHaveBeenCalledTimes(1); + + await unmount(renderer); + + const remounted = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate).toHaveBeenCalledTimes(1); + + await press(first(pressableByLabel(remounted.root, `Preview ${uuid}.md`))); + await flushAsync(); + + const markdown = findByType(remounted.root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + + await unmount(remounted); + }); + + it('retries the presign after an image presign failure', async () => { + const uuid = '88888888-8888-4888-8888-888888888888'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.png`, + mime: 'image/png', + filename: `${uuid}.png`, + }); + getAttachmentDownloadUrlMutate.mockRejectedValueOnce(new Error('presign failed')); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: `${uuid}.png`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(1); + expect(texts(root)).toContain('Image unavailable'); + + await press(first(pressableByLabel(root, 'Image unavailable, retry loading'))); + await flushAsync(); + + const image = findByType(root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + expect(image.props.source).toEqual({ uri: 'https://r2.example/signed' }); + + await unmount(renderer); + }); + + it('toasts "Preview unavailable" for a part with no URL and no cache entry', async () => { + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'application/pdf', filename: 'report.pdf', url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + await press(first(pressableByLabel(root, 'Open report.pdf'))); + + expect(toastMock.error).toHaveBeenCalledWith('Preview unavailable'); + expect(getAttachmentDownloadUrlMutate).not.toHaveBeenCalled(); + + await unmount(renderer); + }); + + it('re-presigns on modal Retry after a download failure', async () => { + expoFileSystemMock.fileText.mockResolvedValue('# Attachment'); + const uuid = '44444444-4444-4444-8444-444444444444'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + shareRemoteFileMock.downloadRemoteFile.mockRejectedValueOnce(new Error('R2 404')); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + await flushAsync(); + + expect(texts(root)).toContain('Could not load this file.'); + const retryButtons = pressableByLabel(root, 'Retry loading file'); + expect(retryButtons).toHaveLength(1); + + const callsBefore = getAttachmentDownloadUrlMutate.mock.calls.length; + await press(first(retryButtons)); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate.mock.calls.length).toBe(callsBefore + 1); + + const markdown = findByType(root, 'ChatMarkdownText'); + expect(markdown).toHaveLength(1); + expect(markdown[0]?.props.value).toBe('# Attachment'); + + await unmount(renderer); + }); + + it('shows "This file is empty." for an empty markdown attachment', async () => { + expoFileSystemMock.fileText.mockResolvedValue(''); + const uuid = '55555555-5555-4555-8555-555555555555'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await flushAsync(); + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + await flushAsync(); + + expect(texts(root)).toContain('This file is empty.'); + + await unmount(renderer); + }); + + it('re-presigns on image retry when the cache entry carries an attachment ref', async () => { + const uuid = '99999999-9999-4999-8999-999999999999'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.png`, + mime: 'image/png', + filename: `${uuid}.png`, + }); + overwriteFilePartCacheEntry('part-1', { + url: 'https://r2.example/signed', + mime: 'image/png', + filename: `${uuid}.png`, + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: `${uuid}.png`, url: '' }) + ); + const root = renderer.root; + + const image = findByType(root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + await act(async () => { + await Promise.resolve(); + (image.props.onError as () => void)(); + }); + + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(1); + + const callsBefore = getAttachmentDownloadUrlMutate.mock.calls.length; + await press(first(pressableByLabel(root, 'Image unavailable, retry loading'))); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate.mock.calls.length).toBe(callsBefore + 1); + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(0); + const reRendered = findByType(root, 'Image')[0]; + expect(reRendered?.props.source).toEqual({ uri: 'https://r2.example/signed' }); + + await unmount(renderer); + }); + + it('re-renders the same URL on image retry when there is no attachment ref', async () => { + cacheFilePart('part-1', { + url: 'data:image/png;base64,QUJD', + mime: 'image/png', + filename: 'shot.png', + }); + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'image/png', filename: 'shot.png', url: '' }) + ); + const root = renderer.root; + + const image = findByType(root, 'Image')[0]; + if (!image) { + throw new Error('image not found'); + } + expect(image.props.source).toEqual({ uri: 'file:///cache/session-file-parts/part-1-shot.png' }); + + await act(async () => { + await Promise.resolve(); + (image.props.onError as () => void)(); + }); + + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(1); + + await press(first(pressableByLabel(root, 'Image unavailable, retry loading'))); + await flushAsync(); + + expect(getAttachmentDownloadUrlMutate).not.toHaveBeenCalled(); + expect(pressableByLabel(root, 'Image unavailable, retry loading')).toHaveLength(0); + const reRendered = findByType(root, 'Image')[0]; + expect(reRendered?.props.source).toEqual({ + uri: 'file:///cache/session-file-parts/part-1-shot.png', + }); + + await unmount(renderer); + }); + + it('toasts when a markdown tap during the presign is followed by a presign failure', async () => { + const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + cacheFilePart('part-1', { + url: `file:///tmp/attachments/agent-1/user-1/${uuid}/${uuid}.md`, + mime: 'text/markdown', + filename: `${uuid}.md`, + }); + const presignHolder: { reject?: (error: Error) => void } = {}; + getAttachmentDownloadUrlMutate.mockReturnValueOnce( + new Promise((_resolve, reject) => { + presignHolder.reject = reject; + }) + ); + + const renderer = await mount( + makeFilePart({ id: 'part-1', mime: 'text/markdown', filename: `${uuid}.md`, url: '' }) + ); + const root = renderer.root; + + await press(first(pressableByLabel(root, `Preview ${uuid}.md`))); + expect(findByType(root, 'Modal')).toHaveLength(0); + + await act(async () => { + presignHolder.reject?.(new Error('presign failed')); + await Promise.resolve(); + }); + await flushAsync(); + + expect(toastMock.error).toHaveBeenCalledWith('Could not load this file. Try again.'); + expect(findByType(root, 'Modal')).toHaveLength(0); + + await unmount(renderer); + }); }); diff --git a/apps/mobile/src/components/agents/file-part-renderer.tsx b/apps/mobile/src/components/agents/file-part-renderer.tsx index 4f300e2cf8..f21a16e1cc 100644 --- a/apps/mobile/src/components/agents/file-part-renderer.tsx +++ b/apps/mobile/src/components/agents/file-part-renderer.tsx @@ -22,8 +22,8 @@ import { } from '@/lib/share-remote-file'; import { ChatMarkdownText } from './chat-markdown-text'; -import { isUsableFilePartUrl, useFilePartCache } from './file-part-cache'; import { getFilePartAccessibilityLabel, getFilePartKind } from './file-part-preview'; +import { refreshFilePartUrl, useResolvedFilePartUrl } from './file-part-url-resolver'; import { stripDataUrlBase64Prefix } from './tool-card-image-cache'; const CACHE_DIR_NAME = 'session-file-parts'; @@ -94,24 +94,12 @@ type FilePartRendererProps = { type PreviewMode = 'markdown' | 'text'; -/** Prefer the captured cache URL, falling back to the part's own URL. The - * cached URL is produced by our cache and is always trusted. */ -function resolveUsableUrl(cachedUrl: string | undefined, partUrl: string): string | undefined { - if (cachedUrl) { - return cachedUrl; - } - if (isUsableFilePartUrl(partUrl)) { - return partUrl; - } - return undefined; -} - export function FilePartRenderer({ part }: Readonly) { const colors = useThemeColors(); const { showActionSheetWithOptions } = useActionSheet(); - const cached = useFilePartCache(part.id); - const url = resolveUsableUrl(cached?.url, part.url); + const resolved = useResolvedFilePartUrl(part); + const url = resolved.status === 'ready' ? resolved.url : undefined; const kind = getFilePartKind({ mime: part.mime, filename: part.filename }); const [viewerVisible, setViewerVisible] = useState(false); @@ -141,41 +129,72 @@ export function FilePartRenderer({ part }: Readonly) { } function handleChipTap() { - if (!url) { - toast.error('Preview unavailable'); + if (url) { + if (kind === 'markdown') { + setPreview('markdown'); + return; + } + showActionSheetWithOptions( + { + options: ['Open as text', 'Open in external app', 'Cancel'], + cancelButtonIndex: 2, + }, + index => { + if (index === undefined || index === 2) { + return; + } + if (index === 0) { + setPreview('text'); + } else if (index === 1) { + void handleShare(); + } + } + ); return; } - if (kind === 'markdown') { - setPreview('markdown'); + if (resolved.status === 'resolving') { + // A markdown chip tapped while the presign is in flight opens the + // modal as soon as the URL lands; the modal render is gated on `url`. + if (kind === 'markdown') { + setPreview('markdown'); + } return; } - showActionSheetWithOptions( - { - options: ['Open as text', 'Open in external app', 'Cancel'], - cancelButtonIndex: 2, - }, - index => { - if (index === undefined || index === 2) { - return; - } - if (index === 0) { - setPreview('text'); - } else if (index === 1) { - void handleShare(); - } - } - ); + if (resolved.status === 'error') { + resolved.retry?.(); + toast.error('Could not load this file. Try again.'); + return; + } + toast.error('Preview unavailable'); } + // A markdown tap during the presign sets `preview` before the URL lands. + // If the presign then fails, surface the failure instead of a silent no-op. + useEffect(() => { + if (preview !== null && resolved.status === 'error') { + toast.error('Could not load this file. Try again.'); + setPreview(null); + } + }, [preview, resolved.status]); + if (kind === 'image') { if (url) { if (imageFailed) { return ( { - setImageFailed(false); + if (!resolved.attachmentRef) { + setImageFailed(false); + return; + } + void (async () => { + const ok = await refreshFilePartUrl(part.id); + if (ok) { + setImageFailed(false); + } + })(); }} - className="my-1 flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 dark:bg-neutral-900" + className="my-1 flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 active:opacity-80 dark:bg-neutral-900" accessibilityRole="button" accessibilityLabel="Image unavailable, retry loading" > @@ -222,6 +241,32 @@ export function FilePartRenderer({ part }: Readonly) { ); } + if (resolved.status === 'resolving') { + return ( + + + Loading image + + ); + } + if (resolved.status === 'error') { + return ( + { + resolved.retry?.(); + }} + className="my-1 flex-row items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 active:opacity-80 dark:bg-neutral-900" + accessibilityRole="button" + accessibilityLabel="Image unavailable, retry loading" + > + + Image unavailable + + ); + } return ( @@ -235,12 +280,16 @@ export function FilePartRenderer({ part }: Readonly) { - {sharing ? : } + {sharing || resolved.status === 'resolving' ? ( + + ) : ( + + )} {part.filename ?? 'File'} @@ -250,6 +299,14 @@ export function FilePartRenderer({ part }: Readonly) { mode={preview} url={url} part={part} + onRetry={ + resolved.attachmentRef + ? async () => { + const ok = await refreshFilePartUrl(part.id); + return ok; + } + : undefined + } onClose={() => { setPreview(null); }} @@ -263,10 +320,11 @@ type FilePreviewModalProps = { mode: PreviewMode; url: string; part: FilePart; + onRetry?: () => Promise; onClose: () => void; }; -function FilePreviewModal({ mode, url, part, onClose }: Readonly) { +function FilePreviewModal({ mode, url, part, onRetry, onClose }: Readonly) { const { id, mime, filename } = part; const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); const [text, setText] = useState(''); @@ -306,7 +364,19 @@ function FilePreviewModal({ mode, url, part, onClose }: ReadonlyCould not load this file. { - setAttempt(prev => prev + 1); + if (!onRetry) { + setAttempt(prev => prev + 1); + return; + } + setStatus('loading'); + void (async () => { + const ok = await onRetry(); + if (!ok) { + setStatus('error'); + } else { + setAttempt(prev => prev + 1); + } + })(); }} accessibilityRole="button" accessibilityLabel="Retry loading file" diff --git a/apps/mobile/src/components/agents/file-part-url-resolver.ts b/apps/mobile/src/components/agents/file-part-url-resolver.ts new file mode 100644 index 0000000000..6257828ca9 --- /dev/null +++ b/apps/mobile/src/components/agents/file-part-url-resolver.ts @@ -0,0 +1,120 @@ +import { type FilePart } from '@kilocode/cloud-agent-sdk'; +import { useEffect } from 'react'; + +import { trpcClient } from '@/lib/trpc'; + +import { + clearFilePartResolveFailed, + getFilePartCacheEntry, + isUsableFilePartUrl, + markFilePartResolveFailed, + overwriteFilePartCacheEntry, + useFilePartCache, +} from './file-part-cache'; +import { type CloudAgentAttachmentRef, parseCloudAgentAttachmentUrl } from './file-part-preview'; + +/** Part IDs with an on-demand presign in flight. Dedupes a StrictMode + * double-mount and a leave/reopen during the mutate. */ +const inFlight = new Set(); + +export type ResolvedFilePartUrl = { + status: 'ready' | 'resolving' | 'unavailable' | 'error'; + // set only when status === 'ready' + url?: string; + // set whenever a ref is known + attachmentRef?: CloudAgentAttachmentRef; + // set only when status === 'error' + retry?: () => void; +}; + +/** + * Resolve a usable URL for a FilePart. A captured `http(s)`/`data:` URL (or a + * cached one) is used directly. A cloud-agent sandbox `file://` attachment is + * presigned on demand via `getAttachmentDownloadUrl`. Failure state lives in + * the cache store so a remounted instance sees a failed presign instead of a + * stuck `resolving`. + */ +export function useResolvedFilePartUrl(part: FilePart): ResolvedFilePartUrl { + const cached = useFilePartCache(part.id); + + const url = cached?.url ?? (isUsableFilePartUrl(part.url) ? part.url : undefined); + const ref = cached?.attachmentRef ?? parseCloudAgentAttachmentUrl(part.url); + const failed = cached?.resolveFailed === true; + + useEffect(() => { + const entry = getFilePartCacheEntry(part.id); + if (entry?.url || entry?.resolveFailed) { + return; + } + const attachmentRef = entry?.attachmentRef ?? parseCloudAgentAttachmentUrl(part.url); + if (!attachmentRef || inFlight.has(part.id)) { + return; + } + inFlight.add(part.id); + void (async () => { + try { + const result = await trpcClient.cloudAgentNext.getAttachmentDownloadUrl.mutate({ + messageUuid: attachmentRef.messageUuid, + filename: attachmentRef.filename, + }); + overwriteFilePartCacheEntry(part.id, { + url: result.signedUrl, + mime: part.mime, + filename: part.filename, + }); + } catch { + markFilePartResolveFailed(part.id); + } finally { + inFlight.delete(part.id); + } + })(); + }, [part.id, part.url, part.mime, part.filename, cached]); + + if (url !== undefined) { + return { status: 'ready', url, ...(ref ? { attachmentRef: ref } : {}) }; + } + if (!ref) { + return { status: 'unavailable' }; + } + if (failed) { + return { + status: 'error', + attachmentRef: ref, + retry: () => { + clearFilePartResolveFailed(part.id); + }, + }; + } + return { status: 'resolving', attachmentRef: ref }; +} + +/** + * Re-presign a cached attachment ref and swap the entry's URL. Returns false + * (never throws) when there is no entry, no ref, or the presign fails. + */ +export async function refreshFilePartUrl(partId: string): Promise { + const entry = getFilePartCacheEntry(partId); + const ref = entry?.attachmentRef; + if (!entry || !ref) { + return false; + } + try { + const result = await trpcClient.cloudAgentNext.getAttachmentDownloadUrl.mutate({ + messageUuid: ref.messageUuid, + filename: ref.filename, + }); + overwriteFilePartCacheEntry(partId, { + url: result.signedUrl, + mime: entry.mime, + ...(entry.filename ? { filename: entry.filename } : {}), + }); + return true; + } catch { + return false; + } +} + +/** Test-only: clear the in-flight set between cases. */ +export function __resetFilePartUrlResolverForTests(): void { + inFlight.clear(); +} diff --git a/services/cloud-agent-next/src/shared/ingest-frame.test.ts b/services/cloud-agent-next/src/shared/ingest-frame.test.ts index b6146985cd..ac46f583fb 100644 --- a/services/cloud-agent-next/src/shared/ingest-frame.test.ts +++ b/services/cloud-agent-next/src/shared/ingest-frame.test.ts @@ -7,6 +7,7 @@ import { isLifecycleIngestEvent, prepareIngestFrame, } from './ingest-frame.js'; +import { MAX_INLINE_FILE_URL_LENGTH } from './trim-payload.js'; import type { IngestEvent } from './protocol.js'; describe('prepareIngestFrame', () => { @@ -23,7 +24,7 @@ describe('prepareIngestFrame', () => { expect(frame.bytes).toBeLessThanOrEqual(MAX_INGEST_EVENT_BYTES); }); - it('applies payload trimming to file parts before serialization', () => { + it('preserves a small inline data: URL on a top-level file part and strips source.text.value', () => { const rawDataUrl = 'data:image/png;base64,wrapper-private-image'; const rawSourceText = 'wrapper private source text'; @@ -41,6 +42,30 @@ describe('prepareIngestFrame', () => { timestamp: '2026-04-14T08:00:00.000Z', }); + expect(frame.kind).toBe('send'); + if (frame.kind !== 'send') return; + expect(frame.serialized).toContain(rawDataUrl); + expect(frame.serialized).not.toContain(rawSourceText); + }); + + it('strips a large inline data: URL from a top-level file part and strips source.text.value', () => { + const rawDataUrl = 'data:image/png;base64,' + 'x'.repeat(MAX_INLINE_FILE_URL_LENGTH); + const rawSourceText = 'wrapper private source text'; + + const frame = prepareIngestFrame({ + streamEventType: 'kilocode', + data: { + event: 'message.part.updated', + type: 'message.part.updated', + part: { + type: 'file', + url: rawDataUrl, + source: { text: { value: rawSourceText } }, + }, + }, + timestamp: '2026-04-14T08:00:00.000Z', + }); + expect(frame.kind).toBe('send'); if (frame.kind !== 'send') return; expect(frame.serialized).not.toContain(rawDataUrl); diff --git a/services/cloud-agent-next/src/shared/trim-payload.test.ts b/services/cloud-agent-next/src/shared/trim-payload.test.ts index 1e05ed1a7c..0bc680fdff 100644 --- a/services/cloud-agent-next/src/shared/trim-payload.test.ts +++ b/services/cloud-agent-next/src/shared/trim-payload.test.ts @@ -5,6 +5,7 @@ import { MAX_TOOL_OUTPUT_LENGTH, MAX_RAW_INPUT_LENGTH, MAX_STDOUT_LENGTH, + MAX_INLINE_FILE_URL_LENGTH, } from './trim-payload.js'; function createKilocodeEvent(event: string, properties: unknown) { @@ -246,7 +247,7 @@ describe('trimPayload', () => { }); describe('message.part.updated — file part', () => { - it('strips url and source.text.value', () => { + it('preserves a small data: url and strips source.text.value', () => { const data = createKilocodeEvent('message.part.updated', { part: { type: 'file', @@ -274,14 +275,14 @@ describe('trimPayload', () => { }; }; - expect(result.properties.part.url).toBe(''); + expect(result.properties.part.url).toBe('data:image/png;base64,abc123'); expect(result.properties.part.source.text.value).toBe(''); expect(result.properties.part.name).toBe('image.png'); expect(result.properties.part.source.type).toBe('file'); expect(result.properties.part.source.path).toBe('/foo'); }); - it('strips only url when source is absent', () => { + it('preserves a small data: url when source is absent', () => { const data = createKilocodeEvent('message.part.updated', { part: { type: 'file', @@ -294,11 +295,101 @@ describe('trimPayload', () => { properties: { part: { url: string; name: string } }; }; + expect(result.properties.part.url).toBe('data:image/png;base64,abc123'); + expect(result.properties.part.name).toBe('image.png'); + }); + + it('strips a large data: url on a top-level file part', () => { + const largeDataUrl = `data:image/png;base64,${'A'.repeat(MAX_INLINE_FILE_URL_LENGTH + 1)}`; + const data = createKilocodeEvent('message.part.updated', { + part: { + type: 'file', + url: largeDataUrl, + name: 'image.png', + source: { + text: { value: 'file content here', start: 0, end: 50 }, + type: 'file', + path: '/foo', + }, + }, + }); + + const result = trimPayload('kilocode', data) as { + properties: { part: { url: string; name: string } }; + }; + expect(result.properties.part.url).toBe(''); expect(result.properties.part.name).toBe('image.png'); }); - it('strips top-level url and source.text.value', () => { + it('preserves a file:// url and strips only source.text.value', () => { + const data = createKilocodeEvent('message.part.updated', { + part: { + type: 'file', + url: 'file:///tmp/attachments/agent_1/user_1/4ad3d9d2-b461-4a55-ac05-ae970f22ee2a/brief.md', + name: 'brief.md', + source: { + text: { value: 'file content here', start: 0, end: 50 }, + type: 'file', + path: '/tmp/attachments/agent_1/user_1/4ad3d9d2-b461-4a55-ac05-ae970f22ee2a/brief.md', + }, + }, + }); + + const result = trimPayload('kilocode', data) as { + properties: { + part: { + url: string; + name: string; + source: { + text: { value: string; start: number; end: number }; + type: string; + path: string; + }; + }; + }; + }; + + expect(result.properties.part.url).toBe( + 'file:///tmp/attachments/agent_1/user_1/4ad3d9d2-b461-4a55-ac05-ae970f22ee2a/brief.md' + ); + expect(result.properties.part.source.text.value).toBe(''); + expect(result.properties.part.name).toBe('brief.md'); + expect(result.properties.part.source.type).toBe('file'); + expect(result.properties.part.source.path).toBe( + '/tmp/attachments/agent_1/user_1/4ad3d9d2-b461-4a55-ac05-ae970f22ee2a/brief.md' + ); + }); + + it('preserves an http(s) url and strips only source.text.value', () => { + const data = createKilocodeEvent('message.part.updated', { + part: { + type: 'file', + url: 'https://example.com/brief.md', + name: 'brief.md', + source: { + text: { value: 'file content here', start: 0, end: 50 }, + type: 'file', + path: '/foo', + }, + }, + }); + + const result = trimPayload('kilocode', data) as { + properties: { + part: { + url: string; + name: string; + source: { text: { value: string }; type: string; path: string }; + }; + }; + }; + + expect(result.properties.part.url).toBe('https://example.com/brief.md'); + expect(result.properties.part.source.text.value).toBe(''); + }); + + it('preserves a small top-level data: url and strips source.text.value', () => { const data = { event: 'message.part.updated', part: { @@ -325,7 +416,7 @@ describe('trimPayload', () => { }; }; - expect(result.part.url).toBe(''); + expect(result.part.url).toBe('data:image/png;base64,top-level'); expect(result.part.source.text.value).toBe(''); expect(result.part.name).toBe('image.png'); expect(result.part.source.type).toBe('file'); @@ -334,7 +425,7 @@ describe('trimPayload', () => { expect(result.part.source.text.end).toBe(50); }); - it('strips both top-level and properties file parts', () => { + it('preserves small data: urls on both top-level and properties file parts', () => { const data = { event: 'message.part.updated', part: { @@ -356,9 +447,9 @@ describe('trimPayload', () => { properties: { part: { url: string; source: { text: { value: string } } } }; }; - expect(result.part.url).toBe(''); + expect(result.part.url).toBe('data:image/png;base64,top-level'); expect(result.part.source.text.value).toBe(''); - expect(result.properties.part.url).toBe(''); + expect(result.properties.part.url).toBe('data:image/png;base64,properties'); expect(result.properties.part.source.text.value).toBe(''); }); }); diff --git a/services/cloud-agent-next/src/shared/trim-payload.ts b/services/cloud-agent-next/src/shared/trim-payload.ts index 8d1ddacf81..a1d72fdc27 100644 --- a/services/cloud-agent-next/src/shared/trim-payload.ts +++ b/services/cloud-agent-next/src/shared/trim-payload.ts @@ -4,6 +4,15 @@ export const MAX_TOOL_OUTPUT_LENGTH = 10_000; export const MAX_RAW_INPUT_LENGTH = 10_000; export const MAX_STDOUT_LENGTH = 10_000; +/** + * Inline `data:` URLs up to this length are preserved on top-level file parts + * so the mobile client can render small inlined images live. Half the 1 MiB + * ingest-frame budget (`MAX_INGEST_EVENT_BYTES` in `ingest-frame.ts`), so a + * single inline file part never alone exceeds it. Larger `data:` URLs are + * stripped for size. + */ +export const MAX_INLINE_FILE_URL_LENGTH = 512 * 1024; + function isRecord(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } @@ -13,8 +22,25 @@ function truncate(s: string, max: number): string { return s.slice(0, max) + '\n\n[…truncated]'; } -function stripFilePartFields(part: Record): Record { - const out: Record = { ...part, url: '' }; +function stripFilePartFields( + part: Record, + options?: { preserveSmallDataUrls?: boolean } +): Record { + // Preserve small, non-`data:` URLs (`file://` sandbox paths, `http(s)`) and, + // on top-level file parts, small inline `data:` URLs. The mobile client + // captures the cloud-agent attachment reference from the raw `file://` URL + // before the SDK strip, and renders small inline `data:` images directly; + // the live stream must keep both. Large `data:` URLs carry inline base64 + // bytes and are stripped for size. + const url = part.url; + const stripDataUrl = + typeof url === 'string' && + url.startsWith('data:') && + (!options?.preserveSmallDataUrls || url.length > MAX_INLINE_FILE_URL_LENGTH); + const out: Record = { + ...part, + ...(stripDataUrl ? { url: '' } : {}), + }; const source = part.source; if (isRecord(source)) { const text = source.text; @@ -55,7 +81,7 @@ function trimPart(part: Record): Record { } if (partType === 'file') { - return stripFilePartFields(part); + return stripFilePartFields(part, { preserveSmallDataUrls: true }); } if (partType === 'tool') {