mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
fix(desktop): upload Windows attachments to Linux backends
This commit is contained in:
parent
d4221b2736
commit
15317e1fd7
4 changed files with 155 additions and 16 deletions
|
|
@ -169,7 +169,12 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
|
|||
}
|
||||
|
||||
if (attachment.kind === 'image' || attachment.kind === 'file') {
|
||||
const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
|
||||
const next = await uploadComposerAttachment(attachment, {
|
||||
backendCwd: readState()?.cwd,
|
||||
remote,
|
||||
requestGateway,
|
||||
sessionId
|
||||
})
|
||||
|
||||
if (options.updateComposerAttachments ?? true) {
|
||||
scope.attachments.update(next)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { $notifications, clearNotifications } from '@/store/notifications'
|
|||
import {
|
||||
$busy,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$currentUsage,
|
||||
$messages,
|
||||
$sessions,
|
||||
|
|
@ -2128,6 +2129,7 @@ describe('usePromptActions file attachment sync', () => {
|
|||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
$currentCwd.set('')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
|
|
@ -2190,6 +2192,100 @@ describe('usePromptActions file attachment sync', () => {
|
|||
})
|
||||
})
|
||||
|
||||
|
||||
it('uploads Windows file bytes when local mode fronts a POSIX WSL/Docker backend', async () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
$currentCwd.set('/root')
|
||||
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,aGVsbG8=')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl }
|
||||
})
|
||||
|
||||
const attachment: ComposerAttachment = {
|
||||
...fileAttachment(),
|
||||
path: 'C:\\Users\\alice\\Downloads\\report.txt',
|
||||
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
|
||||
}
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'file.attach') {
|
||||
return {
|
||||
attached: true,
|
||||
path: '/root/.hermes/desktop-attachments/report.txt',
|
||||
ref_text: '@file:.hermes/desktop-attachments/report.txt',
|
||||
uploaded: true
|
||||
} as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
expect(await handle!.submitText('summarize', { attachments: [attachment] })).toBe(true)
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Downloads\\report.txt')
|
||||
expect(calls[0]).toEqual({
|
||||
method: 'file.attach',
|
||||
params: {
|
||||
data_url: 'data:text/plain;base64,aGVsbG8=',
|
||||
name: 'report.txt',
|
||||
path: 'C:\\Users\\alice\\Downloads\\report.txt',
|
||||
session_id: RUNTIME_SESSION_ID
|
||||
}
|
||||
})
|
||||
expect(calls[1]).toEqual({
|
||||
method: 'prompt.submit',
|
||||
params: { session_id: RUNTIME_SESSION_ID, text: '@file:.hermes/desktop-attachments/report.txt\n\nsummarize' }
|
||||
})
|
||||
})
|
||||
|
||||
it('uses image.attach_bytes for a Windows image when the local backend cwd is POSIX', async () => {
|
||||
const readFileDataUrl = vi.fn(async () => 'data:image/jpeg;base64,aGVsbG8=')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl }
|
||||
})
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'image.attach_bytes') {
|
||||
return { attached: true, path: '/root/tmp/photo.jpg' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
const uploaded = await uploadComposerAttachment(
|
||||
{
|
||||
id: 'image:photo.jpg',
|
||||
kind: 'image',
|
||||
label: 'photo.jpg',
|
||||
path: 'C:\\Users\\alice\\Pictures\\photo.jpg'
|
||||
},
|
||||
{
|
||||
backendCwd: '/root',
|
||||
remote: false,
|
||||
requestGateway,
|
||||
sessionId: RUNTIME_SESSION_ID
|
||||
}
|
||||
)
|
||||
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Pictures\\photo.jpg')
|
||||
expect(requestGateway).toHaveBeenCalledWith('image.attach_bytes', {
|
||||
content_base64: 'aGVsbG8=',
|
||||
filename: 'photo.jpg',
|
||||
session_id: RUNTIME_SESSION_ID
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('image.attach', expect.anything())
|
||||
expect(uploaded.path).toBe('/root/tmp/photo.jpg')
|
||||
})
|
||||
|
||||
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
|
||||
// Submit-layer contract: only attachments that carry a `path` are upload
|
||||
// candidates. A path-less ref (an @-mention/context ref or pasted text)
|
||||
|
|
@ -2237,8 +2333,20 @@ describe('usePromptActions file attachment sync', () => {
|
|||
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
|
||||
})
|
||||
|
||||
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
|
||||
it('passes a Windows path directly for a native Windows local backend', async () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
$currentCwd.set('C:\\Users\\alice\\project')
|
||||
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,c2hvdWxkLW5vdC1iZS1yZWFk')
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl }
|
||||
})
|
||||
|
||||
const attachment: ComposerAttachment = {
|
||||
...fileAttachment(),
|
||||
path: 'C:\\Users\\alice\\Downloads\\report.txt',
|
||||
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
|
||||
}
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
|
||||
|
|
@ -2257,11 +2365,12 @@ describe('usePromptActions file attachment sync', () => {
|
|||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
|
||||
const ok = await handle!.submitText('summarize', { attachments: [attachment] })
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls[0]?.method).toBe('file.attach')
|
||||
// Local mode sends no data_url — the gateway shares this disk.
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
// Native Windows local mode shares the same path namespace.
|
||||
expect(calls[0]?.params).not.toHaveProperty('data_url')
|
||||
expect(calls[1]).toEqual({
|
||||
method: 'prompt.submit',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { clearAllPrompts } from '@/store/prompts'
|
|||
import {
|
||||
$busy,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$messages,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
|
|
@ -76,26 +77,38 @@ interface HandoffResult {
|
|||
error?: string
|
||||
}
|
||||
|
||||
const WINDOWS_ABSOLUTE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\\\\)/
|
||||
const POSIX_ABSOLUTE_PATH_RE = /^\/(?!\/)/
|
||||
|
||||
// `mode: local` means the gateway was launched locally, not necessarily that
|
||||
// Electron and the gateway share a filesystem. Windows Desktop can front a
|
||||
// WSL/Docker backend whose cwd is POSIX, so a Windows host path must cross the
|
||||
// boundary as bytes just like a remote attachment.
|
||||
function attachmentPathNeedsUpload(path: string, backendCwd?: null | string): boolean {
|
||||
return WINDOWS_ABSOLUTE_PATH_RE.test(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage one file/image attachment into the session workspace and return the
|
||||
* attachment rewritten with the gateway-side ref. Images upload their bytes in
|
||||
* remote mode (so vision works) and pass the path locally; non-image files
|
||||
* upload bytes remotely and pass the path locally. Throws on failure so callers
|
||||
* can surface an error. Shared by submit-time sync, the eager drop-time upload,
|
||||
* and the message-edit composer drop — keep them in lockstep.
|
||||
* attachment rewritten with the gateway-side ref. Attachments upload their
|
||||
* bytes for remote gateways and local cross-filesystem backends; otherwise the
|
||||
* gateway receives the shared local path. Throws on failure so callers can
|
||||
* surface an error. Shared by submit-time sync, the eager drop-time upload, and
|
||||
* the message-edit composer drop — keep them in lockstep.
|
||||
*/
|
||||
export async function uploadComposerAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
|
||||
opts: { backendCwd?: null | string; remote: boolean; requestGateway: GatewayRequest; sessionId: string }
|
||||
): Promise<ComposerAttachment> {
|
||||
const { remote, requestGateway, sessionId } = opts
|
||||
const { backendCwd, remote, requestGateway, sessionId } = opts
|
||||
const path = attachment.path ?? ''
|
||||
const label = attachment.label || pathLabel(path)
|
||||
const uploadBytes = remote || attachmentPathNeedsUpload(path, backendCwd)
|
||||
|
||||
if (attachment.kind === 'image') {
|
||||
let result: ImageAttachResponse
|
||||
|
||||
if (remote) {
|
||||
if (uploadBytes) {
|
||||
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
|
||||
|
||||
try {
|
||||
|
|
@ -138,7 +151,7 @@ export async function uploadComposerAttachment(
|
|||
// Non-image file.
|
||||
let dataUrl: string | null = null
|
||||
|
||||
if (remote) {
|
||||
if (uploadBytes) {
|
||||
try {
|
||||
dataUrl = await readFileDataUrlForAttach(path)
|
||||
} catch (err) {
|
||||
|
|
@ -317,7 +330,12 @@ export function usePromptActions({
|
|||
}
|
||||
|
||||
if (attachment.kind === 'image' || attachment.kind === 'file') {
|
||||
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
|
||||
const nextAttachment = await uploadComposerAttachment(attachment, {
|
||||
backendCwd: $currentCwd.get(),
|
||||
remote,
|
||||
requestGateway,
|
||||
sessionId
|
||||
})
|
||||
|
||||
// Update-only: never resurrect a chip the user removed mid-upload.
|
||||
if (updateComposerAttachments) {
|
||||
|
|
@ -356,7 +374,14 @@ export function usePromptActions({
|
|||
try {
|
||||
// Update-only: if the user removed the chip while this was uploading,
|
||||
// don't resurrect it — just drop the staged result on the floor.
|
||||
updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
|
||||
updateComposerAttachment(
|
||||
await uploadComposerAttachment(attachment, {
|
||||
backendCwd: $currentCwd.get(),
|
||||
remote,
|
||||
requestGateway,
|
||||
sessionId
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
// Leave the chip in place so submit-time sync can retry (or the user can
|
||||
// remove it) and flag the card; also toast so a hard failure (unreadable
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
try {
|
||||
const uploaded = await uploadComposerAttachment(
|
||||
{ detail: path, id: attachmentId(kind, path), kind, label: pathLabel(path), path },
|
||||
{ remote, requestGateway, sessionId }
|
||||
{ backendCwd: cwd, remote, requestGateway, sessionId }
|
||||
)
|
||||
|
||||
const ref = attachmentDisplayText(uploaded)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue