diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions-utils.test.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions-utils.test.ts new file mode 100644 index 00000000000..d95f880bb94 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions-utils.test.ts @@ -0,0 +1,127 @@ +import type { AppendMessage } from '@assistant-ui/react' +import { describe, expect, it } from 'vitest' + +import type { ChatMessage } from '@/lib/chat-messages' + +import { + appendText, + base64FromDataUrl, + friendlyRemoteAttachError, + imageFilenameFromPath, + inlineErrorMessage, + isSessionBusyError, + isSessionIdCandidate, + isSessionNotFoundError, + slashStatusText, + visibleUserIndexAtOrdinal, + visibleUserOrdinal +} from './use-prompt-actions-utils' + +describe('isSessionIdCandidate', () => { + it('accepts the timestamped and hex id forms', () => { + expect(isSessionIdCandidate('20260101_120000_abc123')).toBe(true) + expect(isSessionIdCandidate('a'.repeat(32))).toBe(true) + }) + + it('rejects arbitrary text', () => { + expect(isSessionIdCandidate('hello world')).toBe(false) + expect(isSessionIdCandidate('abc')).toBe(false) + }) +}) + +describe('inlineErrorMessage', () => { + it('unwraps an electron remote-method error', () => { + expect(inlineErrorMessage(new Error("Error invoking remote method 'x': Error: boom"), 'fallback')).toBe('boom') + }) + + it('strips a leading Error: prefix', () => { + expect(inlineErrorMessage(new Error('Error: nope'), 'fallback')).toBe('nope') + }) + + it('falls back for non-error, non-string input', () => { + expect(inlineErrorMessage(undefined, 'fallback')).toBe('fallback') + }) +}) + +describe('session error classifiers', () => { + it('detects not-found and busy errors', () => { + expect(isSessionNotFoundError(new Error('Session not found'))).toBe(true) + expect(isSessionBusyError(new Error('session busy'))).toBe(true) + expect(isSessionNotFoundError(new Error('other'))).toBe(false) + expect(isSessionBusyError(new Error('other'))).toBe(false) + }) +}) + +describe('base64FromDataUrl', () => { + it('returns the part after the comma', () => { + expect(base64FromDataUrl('data:image/png;base64,AAAA')).toBe('AAAA') + }) + + it('returns empty when there is no comma', () => { + expect(base64FromDataUrl('nope')).toBe('') + }) +}) + +describe('imageFilenameFromPath', () => { + it('takes the last path segment', () => { + expect(imageFilenameFromPath('/a/b/c.png')).toBe('c.png') + expect(imageFilenameFromPath('C:\\a\\b\\d.jpg')).toBe('d.jpg') + }) + + it('defaults when the path is empty', () => { + expect(imageFilenameFromPath('')).toBe('image.png') + }) +}) + +describe('friendlyRemoteAttachError', () => { + it('rewrites a too-large error with the parsed cap', () => { + const err = friendlyRemoteAttachError(new Error('file is too large (20 bytes; limit 16777216 bytes)'), 'pic.png') + expect(err.message).toBe('pic.png is too large to upload to the remote gateway (max 16 MB).') + }) + + it('passes non-cap errors through', () => { + const original = new Error('something else') + expect(friendlyRemoteAttachError(original, 'pic.png')).toBe(original) + }) +}) + +describe('slashStatusText', () => { + it('joins command and trimmed output', () => { + expect(slashStatusText('/model', ' gpt ')).toBe('slash:/model\ngpt') + }) + + it('omits empty output', () => { + expect(slashStatusText('/clear', ' ')).toBe('slash:/clear') + }) +}) + +describe('appendText', () => { + it('concatenates text parts and trims', () => { + const message = { + content: [ + { type: 'text', text: ' a' }, + { type: 'text', text: 'b ' } + ] + } as unknown as AppendMessage + + expect(appendText(message)).toBe('ab') + }) +}) + +describe('visible user ordinals', () => { + const messages = [ + { role: 'user', hidden: false }, + { role: 'assistant' }, + { role: 'user', hidden: true }, + { role: 'user', hidden: false } + ] as ChatMessage[] + + it('counts visible user messages before an index', () => { + expect(visibleUserOrdinal(messages, messages.length)).toBe(2) + }) + + it('maps an ordinal back to a message index, skipping hidden', () => { + expect(visibleUserIndexAtOrdinal(messages, 1)).toBe(3) + expect(visibleUserIndexAtOrdinal(messages, 5)).toBe(-1) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions-utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions-utils.ts new file mode 100644 index 00000000000..497ad8fdd6c --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions-utils.ts @@ -0,0 +1,211 @@ +import type { AppendMessage } from '@assistant-ui/react' + +import { translateNow, type Translations } from '@/i18n' +import type { ChatMessage } from '@/lib/chat-messages' +import { type CommandsCatalogLike, filterDesktopCommandsCatalog } from '@/lib/desktop-slash-commands' +import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' + +export type GatewayRequest = (method: string, params?: Record) => Promise + +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export function isSessionIdCandidate(value: string): boolean { + const trimmed = value.trim() + + return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed) +} + +export function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.addEventListener('load', () => { + if (typeof reader.result === 'string') { + resolve(reader.result) + } else { + reject(new Error(translateNow('desktop.audioReadFailed'))) + } + }) + reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed')))) + reader.readAsDataURL(blob) + }) +} + +export function isProviderSetupError(error: unknown) { + const message = error instanceof Error ? error.message : String(error) + + return isProviderSetupErrorMessage(message) +} + +export function inlineErrorMessage(error: unknown, fallback: string): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback + + return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() +} + +export function isSessionNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /session not found/i.test(message) +} + +// The gateway refuses prompt.submit while a turn is running (4009 "session +// busy"). It's a transient concurrency guard, never a user-facing error: a +// submit racing the settle edge (or a rewind interrupting mid-turn) just waits +// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck +// turn still surfaces eventually. +export const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000 +export const SESSION_BUSY_RETRY_INTERVAL_MS = 150 + +export function isSessionBusyError(error: unknown): boolean { + return /session busy/i.test(error instanceof Error ? error.message : String(error)) +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +// Retry a gateway call across transient "session busy" so it never reaches the +// user — the turn settles within the deadline and the call lands. +export async function withSessionBusyRetry(call: () => Promise): Promise { + const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS + + for (;;) { + try { + return await call() + } catch (err) { + if (isSessionBusyError(err) && Date.now() < deadline) { + await sleep(SESSION_BUSY_RETRY_INTERVAL_MS) + + continue + } + + throw err + } + } +} + +// Hard guard: at most one prompt.submit in flight per session. Every submit +// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels +// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated +// session whose first call hangs) let the SAME prompt launch several real turns +// at once (the "message stacked 5×" bug). Keyed by stored/active session id. +export const _submitInFlight = new Set() + +export function base64FromDataUrl(dataUrl: string): string { + const comma = dataUrl.indexOf(',') + + return comma >= 0 ? dataUrl.slice(comma + 1) : '' +} + +export function imageFilenameFromPath(filePath: string): string { + return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png' +} + +// Remote gateway: the local composer-image file lives on THIS machine's disk, +// not the gateway's, so read the bytes here and upload them via +// image.attach_bytes. Returns null when the file can't be read. +export async function readImageForRemoteAttach( + filePath: string +): Promise<{ contentBase64: string; filename: string } | null> { + const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath) + const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : '' + + return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null +} + +// Read a non-image file as a data URL for upload via file.attach. Returns null +// when the desktop bridge can't read the file (e.g. it was moved/deleted). +export async function readFileDataUrlForAttach(filePath: string): Promise { + const reader = window.hermesDesktop?.readFileDataUrl + + if (!reader) { + return null + } + + const dataUrl = await reader(filePath) + + return dataUrl || null +} + +// The readFileDataUrl IPC base64-loads the whole file into memory and is +// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which +// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In +// remote mode every attachment's bytes go through that read, so a big file +// surfaces that internal message verbatim in the failure toast. Translate it +// into a friendly "too large to upload to the remote gateway" line, parsing the +// limit out of the message so it tracks the real cap. Non-cap errors pass +// through unchanged. +export function friendlyRemoteAttachError(err: unknown, label: string): Error { + const message = err instanceof Error ? err.message : String(err) + + if (!/too large/i.test(message)) { + return err instanceof Error ? err : new Error(message) + } + + const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1]) + const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : '' + + return new Error(`${label} is too large to upload to the remote gateway${cap}.`) +} + +export function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string { + const desktopCatalog = filterDesktopCommandsCatalog(catalog) + + const sections = desktopCatalog.categories?.length + ? desktopCatalog.categories + : [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }] + + const body = sections + .filter(section => section.pairs.length > 0) + .map(section => { + const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`) + + return [`${section.name}:`, ...rows].join('\n') + }) + .join('\n\n') + + const tail = [ + desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '', + desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : '' + ] + .filter(Boolean) + .join('\n') + + return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n') +} + +export function slashStatusText(command: string, output: string): string { + return [`slash:${command}`, output.trim()].filter(Boolean).join('\n') +} + +export function appendText(message: AppendMessage): string { + return message.content + .map(part => ('text' in part ? part.text : '')) + .join('') + .trim() +} + +export function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number { + return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length +} + +export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number { + let ordinal = 0 + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index] + + if (message.role !== 'user' || message.hidden) { + continue + } + + if (ordinal === targetOrdinal) { + return index + } + + ordinal += 1 + } + + return -1 +} diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 6e2829d6178..b671dfdf8a6 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { getProfiles, transcribeAudio } from '@/hermes' -import { translateNow, type Translations, useI18n } from '@/i18n' +import { useI18n } from '@/i18n' import { stripAnsi } from '@/lib/ansi' import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' import { @@ -19,13 +19,11 @@ import { type DesktopActionId, type DesktopPickerId, desktopSlashUnavailableMessage, - filterDesktopCommandsCatalog, isDesktopSlashCommand, resolveDesktopCommand } from '@/lib/desktop-slash-commands' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' -import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { setSessionYolo } from '@/lib/yolo-session' import { clearClarifyRequest } from '@/store/clarify' import { openCommandPalettePage } from '@/store/command-palette' @@ -76,153 +74,32 @@ import type { SlashExecResponse } from '../../types' +import { + _submitInFlight, + appendText, + blobToDataUrl, + delay, + friendlyRemoteAttachError, + type GatewayRequest, + inlineErrorMessage, + isProviderSetupError, + isSessionBusyError, + isSessionIdCandidate, + isSessionNotFoundError, + readFileDataUrlForAttach, + readImageForRemoteAttach, + renderCommandsCatalog, + slashStatusText, + visibleUserIndexAtOrdinal, + visibleUserOrdinal, + withSessionBusyRetry +} from './use-prompt-actions-utils' + interface HandoffResult { ok: boolean error?: string } -function delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -function isSessionIdCandidate(value: string): boolean { - const trimmed = value.trim() - - return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed) -} - -function blobToDataUrl(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - - reader.addEventListener('load', () => { - if (typeof reader.result === 'string') { - resolve(reader.result) - } else { - reject(new Error(translateNow('desktop.audioReadFailed'))) - } - }) - reader.addEventListener('error', () => reject(reader.error || new Error(translateNow('desktop.audioReadFailed')))) - reader.readAsDataURL(blob) - }) -} - -function isProviderSetupError(error: unknown) { - const message = error instanceof Error ? error.message : String(error) - - return isProviderSetupErrorMessage(message) -} - -function inlineErrorMessage(error: unknown, fallback: string): string { - const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback - - return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim() -} - -function isSessionNotFoundError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - - return /session not found/i.test(message) -} - -// The gateway refuses prompt.submit while a turn is running (4009 "session -// busy"). It's a transient concurrency guard, never a user-facing error: a -// submit racing the settle edge (or a rewind interrupting mid-turn) just waits -// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck -// turn still surfaces eventually. -const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000 -const SESSION_BUSY_RETRY_INTERVAL_MS = 150 - -function isSessionBusyError(error: unknown): boolean { - return /session busy/i.test(error instanceof Error ? error.message : String(error)) -} - -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) - -// Retry a gateway call across transient "session busy" so it never reaches the -// user — the turn settles within the deadline and the call lands. -async function withSessionBusyRetry(call: () => Promise): Promise { - const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS - - for (;;) { - try { - return await call() - } catch (err) { - if (isSessionBusyError(err) && Date.now() < deadline) { - await sleep(SESSION_BUSY_RETRY_INTERVAL_MS) - - continue - } - - throw err - } - } -} - -// Hard guard: at most one prompt.submit in flight per session. Every submit -// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels -// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated -// session whose first call hangs) let the SAME prompt launch several real turns -// at once (the "message stacked 5×" bug). Keyed by stored/active session id. -const _submitInFlight = new Set() - -function base64FromDataUrl(dataUrl: string): string { - const comma = dataUrl.indexOf(',') - - return comma >= 0 ? dataUrl.slice(comma + 1) : '' -} - -function imageFilenameFromPath(filePath: string): string { - return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png' -} - -// Remote gateway: the local composer-image file lives on THIS machine's disk, -// not the gateway's, so read the bytes here and upload them via -// image.attach_bytes. Returns null when the file can't be read. -async function readImageForRemoteAttach(filePath: string): Promise<{ contentBase64: string; filename: string } | null> { - const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath) - const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : '' - - return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null -} - -// Read a non-image file as a data URL for upload via file.attach. Returns null -// when the desktop bridge can't read the file (e.g. it was moved/deleted). -async function readFileDataUrlForAttach(filePath: string): Promise { - const reader = window.hermesDesktop?.readFileDataUrl - - if (!reader) { - return null - } - - const dataUrl = await reader(filePath) - - return dataUrl || null -} - -// The readFileDataUrl IPC base64-loads the whole file into memory and is -// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.cjs, which -// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In -// remote mode every attachment's bytes go through that read, so a big file -// surfaces that internal message verbatim in the failure toast. Translate it -// into a friendly "too large to upload to the remote gateway" line, parsing the -// limit out of the message so it tracks the real cap. Non-cap errors pass -// through unchanged. -function friendlyRemoteAttachError(err: unknown, label: string): Error { - const message = err instanceof Error ? err.message : String(err) - - if (!/too large/i.test(message)) { - return err instanceof Error ? err : new Error(message) - } - - const limitBytes = Number(message.match(/limit (\d+) bytes/)?.[1]) - const cap = Number.isFinite(limitBytes) && limitBytes > 0 ? ` (max ${Math.floor(limitBytes / (1024 * 1024))} MB)` : '' - - return new Error(`${label} is too large to upload to the remote gateway${cap}.`) -} - -type GatewayRequest = (method: string, params?: Record) => Promise - /** * Stage one file/image attachment into the session workspace and return the * attachment rewritten with the gateway-side ref. Images upload their bytes in @@ -350,67 +227,6 @@ interface SlashActionCtx { sessionHint?: string } -function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string { - const desktopCatalog = filterDesktopCommandsCatalog(catalog) - - const sections = desktopCatalog.categories?.length - ? desktopCatalog.categories - : [{ name: copy.desktopCommands, pairs: desktopCatalog.pairs ?? [] }] - - const body = sections - .filter(section => section.pairs.length > 0) - .map(section => { - const rows = section.pairs.map(([cmd, desc]) => `${cmd.padEnd(18)} ${desc}`) - - return [`${section.name}:`, ...rows].join('\n') - }) - .join('\n\n') - - const tail = [ - desktopCatalog.skill_count ? copy.skillCommandsAvailable(desktopCatalog.skill_count) : '', - desktopCatalog.warning ? copy.warningLine(desktopCatalog.warning) : '' - ] - .filter(Boolean) - .join('\n') - - return [body || 'No desktop commands available.', tail].filter(Boolean).join('\n\n') -} - -function slashStatusText(command: string, output: string): string { - return [`slash:${command}`, output.trim()].filter(Boolean).join('\n') -} - -function appendText(message: AppendMessage): string { - return message.content - .map(part => ('text' in part ? part.text : '')) - .join('') - .trim() -} - -function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): number { - return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length -} - -function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number { - let ordinal = 0 - - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index] - - if (message.role !== 'user' || message.hidden) { - continue - } - - if (ordinal === targetOrdinal) { - return index - } - - ordinal += 1 - } - - return -1 -} - interface RestoreMessageTarget { text?: string userOrdinal?: number | null