Skip to content

Commit 29147af

Browse files
committed
fix(desktop): friendlier toast when a remote attachment exceeds the 16MB cap
Remote attachments read their bytes through the readFileDataUrl IPC, which is hard-capped at 16MB and rejects with a raw "file is too large (N bytes; limit M bytes)" string straight into the failure toast (helix4u review note on #43109). Translate that into "<file> is too large to upload to the remote gateway (max 16 MB)", parsing the limit out of the message so it tracks the real cap. Applies to both the image and non-image remote read paths; non-cap errors pass through unchanged. Adds unit coverage for both.
1 parent b021497 commit 29147af

2 files changed

Lines changed: 89 additions & 5 deletions

File tree

apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
77
import { $connection, $sessions, setSessions } from '@/store/session'
88
import type { SessionInfo } from '@/types/hermes'
99

10-
import { usePromptActions } from './use-prompt-actions'
10+
import { uploadComposerAttachment, usePromptActions } from './use-prompt-actions'
1111

1212
vi.mock('@/hermes', () => ({
1313
getProfiles: vi.fn(async () => ({ profiles: [] })),
@@ -703,3 +703,52 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
703703
})
704704
})
705705

706+
describe('uploadComposerAttachment remote read failures', () => {
707+
afterEach(() => {
708+
vi.restoreAllMocks()
709+
})
710+
711+
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
712+
// electron/hardening.cjs rejects the readFileDataUrl IPC with this exact
713+
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
714+
Object.defineProperty(window, 'hermesDesktop', {
715+
configurable: true,
716+
value: {
717+
readFileDataUrl: vi.fn(async () => {
718+
throw new Error('File preview failed: file is too large (20971520 bytes; limit 16777216 bytes).')
719+
})
720+
}
721+
})
722+
723+
const requestGateway = vi.fn(async () => ({}) as never)
724+
725+
await expect(
726+
uploadComposerAttachment(
727+
{ id: 'file:big', kind: 'file', label: 'huge.csv', path: '/abs/huge.csv' },
728+
{ remote: true, requestGateway, sessionId: RUNTIME_SESSION_ID }
729+
)
730+
).rejects.toThrow('huge.csv is too large to upload to the remote gateway (max 16 MB).')
731+
732+
// The cap is hit before any gateway round-trip.
733+
expect(requestGateway).not.toHaveBeenCalled()
734+
})
735+
736+
it('passes non-cap read errors through unchanged', async () => {
737+
Object.defineProperty(window, 'hermesDesktop', {
738+
configurable: true,
739+
value: {
740+
readFileDataUrl: vi.fn(async () => {
741+
throw new Error('ENOENT: no such file')
742+
})
743+
}
744+
})
745+
746+
await expect(
747+
uploadComposerAttachment(
748+
{ id: 'file:gone', kind: 'file', label: 'gone.csv', path: '/abs/gone.csv' },
749+
{ remote: true, requestGateway: vi.fn(async () => ({}) as never), sessionId: RUNTIME_SESSION_ID }
750+
)
751+
).rejects.toThrow('ENOENT: no such file')
752+
})
753+
})
754+

apps/desktop/src/app/session/hooks/use-prompt-actions.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,27 @@ async function readFileDataUrlForAttach(filePath: string): Promise<string | null
120120
return dataUrl || null
121121
}
122122

123+
// The readFileDataUrl IPC base64-loads the whole file into memory and is
124+
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which
125+
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
126+
// remote mode every attachment's bytes go through that read, so a big file
127+
// surfaces that internal message verbatim in the failure toast. Translate it
128+
// into a friendly "too large to upload to the remote gateway" line, parsing the
129+
// limit out of the message so it tracks the real cap. Non-cap errors pass
130+
// through unchanged.
131+
function friendlyRemoteAttachError(err: unknown, label: string): Error {
132+
const message = err instanceof Error ? err.message : String(err)
133+
134+
if (!/too large/i.test(message)) {
135+
return err instanceof Error ? err : new Error(message)
136+
}
137+
138+
const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1])
139+
const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : ''
140+
141+
return new Error(`${label} is too large to upload to the remote gateway${cap}.`)
142+
}
143+
123144
type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
124145

125146
/**
@@ -142,7 +163,13 @@ export async function uploadComposerAttachment(
142163
let result: ImageAttachResponse
143164

144165
if (remote) {
145-
const payload = await readImageForRemoteAttach(path)
166+
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
167+
168+
try {
169+
payload = await readImageForRemoteAttach(path)
170+
} catch (err) {
171+
throw friendlyRemoteAttachError(err, label)
172+
}
146173

147174
if (!payload) {
148175
throw new Error(`Could not read ${label}`)
@@ -176,10 +203,18 @@ export async function uploadComposerAttachment(
176203
}
177204

178205
// Non-image file.
179-
const dataUrl = remote ? await readFileDataUrlForAttach(path) : null
206+
let dataUrl: string | null = null
180207

181-
if (remote && !dataUrl) {
182-
throw new Error(`Could not read ${label}`)
208+
if (remote) {
209+
try {
210+
dataUrl = await readFileDataUrlForAttach(path)
211+
} catch (err) {
212+
throw friendlyRemoteAttachError(err, label)
213+
}
214+
215+
if (!dataUrl) {
216+
throw new Error(`Could not read ${label}`)
217+
}
183218
}
184219

185220
const result = await requestGateway<FileAttachResponse>('file.attach', {

0 commit comments

Comments
 (0)