diff --git a/apps/mobile/src/features/threads/hermes-thread-screen.tsx b/apps/mobile/src/features/threads/hermes-thread-screen.tsx index f470584..819e3af 100644 --- a/apps/mobile/src/features/threads/hermes-thread-screen.tsx +++ b/apps/mobile/src/features/threads/hermes-thread-screen.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; -import { useEffect, useId, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { FlatList, KeyboardAvoidingView, @@ -18,6 +18,8 @@ import { CHAT_CONTENT_MAX_WIDTH, T3Radius, T3Spacing, T3Typography } from '@/con import { useT3Theme } from '@/hooks/use-t3-theme'; import { approveRun, + BrioRequestError, + ensureSession, deleteAttachmentUpload, dispatchComposerCommand, getRun, @@ -98,6 +100,12 @@ type GatewayInputRequest = { questionIndex: number; }; +function createDraftSessionId() { + const timestamp = Date.now().toString(36); + const entropy = Math.random().toString(36).slice(2, 12); + return `brio_new_${timestamp}_${entropy}`; +} + export function HermesThreadScreen({ connection, initialModelOverride, @@ -113,10 +121,13 @@ export function HermesThreadScreen({ const router = useRouter(); const queryClient = useQueryClient(); const listRef = useRef>(null); - const generatedSessionId = `brio_new_${useId().replace(/[^a-z0-9_-]/gi, '')}`; - const sessionId = routeSessionId === 'new' ? generatedSessionId : routeSessionId; - const runKey = `${connection.id}:${profile}:${sessionId}`; const composerKey = `${connection.id}:${profile}:${routeSessionId}`; + const [generatedSessionId] = useState(createDraftSessionId); + const persistedDraftSessionId = useComposerStore((state) => state.sessionIds[composerKey]); + const sessionId = routeSessionId === 'new' + ? (persistedDraftSessionId ?? generatedSessionId) + : routeSessionId; + const runKey = `${connection.id}:${profile}:${sessionId}`; const [modelOverride, setModelOverride] = useState( initialModelOverride, ); @@ -134,6 +145,7 @@ export function HermesThreadScreen({ const setActiveRun = useRunStore((state) => state.setActiveRun); const clearActiveRun = useRunStore((state) => state.clearActiveRun); const composerHydrated = useComposerStore((state) => state.hydrated); + const ensureComposerSessionId = useComposerStore((state) => state.ensureSessionId); const draft = useComposerStore((state) => state.drafts[composerKey] ?? ''); const attachments = useComposerStore( (state) => state.attachments[composerKey] ?? EMPTY_COMPOSER_ATTACHMENTS, @@ -164,6 +176,11 @@ export function HermesThreadScreen({ timestamp: number; } | null>(null); + useEffect(() => { + if (routeSessionId !== 'new' || !composerHydrated) return; + void ensureComposerSessionId(composerKey, generatedSessionId); + }, [composerHydrated, composerKey, ensureComposerSessionId, generatedSessionId, routeSessionId]); + const messages = useQuery({ queryKey: ['session-messages', connection.id, connection.url, profile, sessionId], queryFn: () => getSessionMessages(connection, sessionId, profile), @@ -206,6 +223,28 @@ export function HermesThreadScreen({ const terminal = currentRun && ['completed', 'failed', 'cancelled'].includes(currentRun.status); const active = Boolean(runId && !terminal); + useEffect(() => { + if ( + !runId || + runId.startsWith('gateway:') || + !(run.error instanceof BrioRequestError) || + run.error.status !== 404 + ) { + return; + } + const timer = setTimeout(() => { + const hasQueuedPrompts = queue.length > 0; + if (hasQueuedPrompts) void setQueuePaused(composerKey, true); + setComposerError( + hasQueuedPrompts + ? 'Hermes no longer has the previous run. Queued prompts are paused because its delivery could not be verified.' + : 'Hermes no longer has the previous run. Its final delivery could not be verified.', + ); + clearActiveRun(runKey); + }, 0); + return () => clearTimeout(timer); + }, [clearActiveRun, composerKey, queue.length, run.error, runId, runKey, setQueuePaused]); + useEffect(() => { const acquired = acquireHermesGateway(connection, profile); const gateway = acquired.client; @@ -494,7 +533,18 @@ export function HermesThreadScreen({ mutationFn: async (queuedPrompt: QueuedPrompt) => { const input = queuedPrompt.text.trim(); const promptModelOverride = queuedPrompt.modelOverride ?? modelOverride; + const persistedModel = promptModelOverride + ? { + provider: promptModelOverride.provider, + model: promptModelOverride.model, + model_options: buildRuntimeModelOptions(promptModelOverride), + require_model_lock: true, + } + : undefined; if (input.startsWith('/') && queuedPrompt.attachments.length === 0) { + if (routeSessionId === 'new') { + await ensureSession(connection, sessionId, profile, persistedModel); + } const response = await dispatchComposerCommand( connection, sessionId, @@ -590,6 +640,16 @@ export function HermesThreadScreen({ } } + // The Runs and Responses APIs interpret session_id as an existing + // persisted session. The draft route's generated id is only a local + // correlation key, so create its Hermes session before degrading from + // the WebSocket gateway to REST. Without this step the run is accepted + // asynchronously, navigation succeeds, and transcript loading then + // fails with "Session not found: brio_new_...". + if (routeSessionId === 'new') { + await ensureSession(connection, sessionId, profile, persistedModel); + } + if (advancedPrompt) { const relayExpandsComposer = connection.transport === 'relay'; const responseInput = !relayExpandsComposer diff --git a/apps/mobile/src/lib/brio.test.mjs b/apps/mobile/src/lib/brio.test.mjs index f80bdd6..b9095bc 100644 --- a/apps/mobile/src/lib/brio.test.mjs +++ b/apps/mobile/src/lib/brio.test.mjs @@ -15,8 +15,11 @@ import { } from '../state/connection-store-model.ts'; import { aggregateRootAgentUsage, + BrioRequestError, brioFetch, connectionFromPairingPayload, + createSession, + ensureSession, decodePairingPayload, extractPairingPayload, filterAgentsForControlSession, @@ -43,6 +46,136 @@ test('normalizes current Hermes list envelopes without breaking legacy responses assert.deepEqual(normalizeMessageList({ messages }).messages, messages); }); +test('creates a persisted Hermes session before a REST-backed new thread starts', async () => { + const originalFetch = globalThis.fetch; + let request; + globalThis.fetch = async (input, init) => { + request = { input: String(input), init }; + return new Response(JSON.stringify({ + object: 'hermes.session', + session: { id: 'brio_new_test', source: 'api_server', started_at: 1, message_count: 0 }, + }), { status: 201, headers: { 'Content-Type': 'application/json' } }); + }; + try { + const created = await createSession({ + id: 'direct-1', + name: 'Hermes', + mode: 'self_hosted', + transport: 'direct', + status: 'online', + capabilities: {}, + url: 'http://127.0.0.1:8787', + token: 'secret', + }, 'brio_new_test', 'coder'); + assert.equal(created.session.id, 'brio_new_test'); + assert.equal(request.input, 'http://127.0.0.1:8787/p/coder/api/sessions'); + assert.equal(request.init.method, 'POST'); + assert.deepEqual(JSON.parse(request.init.body), { id: 'brio_new_test', source: 'api_server' }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('reuses a session created by an earlier failed REST attempt', async () => { + const originalFetch = globalThis.fetch; + const requests = []; + globalThis.fetch = async (input, init) => { + requests.push({ input: String(input), init }); + if (init?.method === 'POST') { + return new Response(JSON.stringify({ + error: { message: 'Session already exists: brio_new_retry' }, + }), { status: 409, headers: { 'Content-Type': 'application/json' } }); + } + return new Response(JSON.stringify({ + object: 'hermes.session', + session: { id: 'brio_new_retry', source: 'api_server', started_at: 1, message_count: 0 }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + try { + const existing = await ensureSession({ + id: 'direct-1', + name: 'Hermes', + mode: 'self_hosted', + transport: 'direct', + status: 'online', + capabilities: {}, + url: 'http://127.0.0.1:8787', + token: 'secret', + }, 'brio_new_retry'); + assert.equal(existing.session.id, 'brio_new_retry'); + assert.equal(requests.length, 2); + assert.equal(requests[1].input, 'http://127.0.0.1:8787/api/sessions/brio_new_retry'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('persists a selected model after ensuring a retry-safe session', async () => { + const originalFetch = globalThis.fetch; + const requests = []; + globalThis.fetch = async (input, init) => { + requests.push({ input: String(input), init }); + if (String(input).endsWith('/model')) { + return new Response(JSON.stringify({ + provider: 'openrouter', + model: 'test/model', + model_options: { reasoning: { enabled: true, effort: 'high' } }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Response(JSON.stringify({ + object: 'hermes.session', + session: { id: 'brio_new_model', source: 'api_server', started_at: 1, message_count: 0 }, + }), { status: 201, headers: { 'Content-Type': 'application/json' } }); + }; + try { + await ensureSession({ + id: 'direct-1', + name: 'Hermes', + mode: 'self_hosted', + transport: 'direct', + status: 'online', + capabilities: {}, + url: 'http://127.0.0.1:8787', + token: 'secret', + }, 'brio_new_model', 'coder', { + provider: 'openrouter', + model: 'test/model', + model_options: { reasoning: { enabled: true, effort: 'high' } }, + require_model_lock: true, + }); + assert.deepEqual(requests.map((request) => request.input), [ + 'http://127.0.0.1:8787/p/coder/api/sessions', + 'http://127.0.0.1:8787/p/coder/api/sessions/brio_new_model/model', + ]); + assert.deepEqual(JSON.parse(requests[1].init.body), { + provider: 'openrouter', + model: 'test/model', + model_options: { reasoning: { enabled: true, effort: 'high' } }, + require_model_lock: true, + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('preserves HTTP status on structured API failures', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(JSON.stringify({ + error: { message: 'Run not found' }, + }), { status: 404, headers: { 'Content-Type': 'application/json' } }); + try { + await assert.rejects( + brioFetch({ url: 'http://127.0.0.1:8787', token: 'secret' }, '/v1/runs/missing'), + (error) => + error instanceof BrioRequestError && + error.status === 404 && + error.message === 'Run not found', + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('normalizes native Hermes health and capabilities for connection screens', () => { assert.deepEqual( normalizeHealth({ status: 'ok', platform: 'hermes-agent', version: '0.20.5' }), diff --git a/apps/mobile/src/lib/brio.ts b/apps/mobile/src/lib/brio.ts index 78d84b6..c6b4062 100644 --- a/apps/mobile/src/lib/brio.ts +++ b/apps/mobile/src/lib/brio.ts @@ -33,6 +33,16 @@ export type AgentConnection = { agentName?: string; }; +export class BrioRequestError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'BrioRequestError'; + this.status = status; + } +} + export type HealthResponse = { ok?: boolean; status?: string; @@ -127,6 +137,11 @@ export type HermesSession = { title?: string; }; +export type HermesSessionCreateResponse = { + object: 'hermes.session'; + session: HermesSession; +}; + export type HermesMessage = { role: string; content: string; @@ -523,7 +538,10 @@ export async function brioFetch( const text = await response.text(); const body = text ? JSON.parse(text) : null; if (!response.ok) { - throw new Error(apiErrorMessage(body?.error ?? body?.message, `Request failed: ${response.status}`)); + throw new BrioRequestError( + apiErrorMessage(body?.error ?? body?.message, `Request failed: ${response.status}`), + response.status, + ); } return body as T; } @@ -682,6 +700,46 @@ export async function listSessions(connection: AgentConnection, limit = 100, pro return normalizeSessionList(response); } +export function createSession( + connection: AgentConnection, + sessionId: string, + profile?: string, +) { + return brioFetch( + connection, + scopedPath('/api/sessions', profile), + { + method: 'POST', + body: JSON.stringify({ id: sessionId, source: 'api_server' }), + }, + ); +} + +export async function ensureSession( + connection: AgentConnection, + sessionId: string, + profile?: string, + modelPayload?: HermesSessionModelPayload, +) { + let session: HermesSessionCreateResponse; + try { + session = await createSession(connection, sessionId, profile); + } catch (createError) { + try { + session = await brioFetch( + connection, + scopedPath(`/api/sessions/${encodeURIComponent(sessionId)}`, profile), + ); + } catch { + throw createError; + } + } + if (modelPayload) { + await setSessionModel(connection, sessionId, modelPayload, profile); + } + return session; +} + export function searchSessions(connection: AgentConnection, query: string, profile?: string) { return brioFetch<{ results: HermesSearchResult[]; error?: string }>( connection, @@ -2164,7 +2222,7 @@ class RelaySocketClient { : undefined, `Request failed: ${frame.status}`, ); - pending.reject(new Error(message)); + pending.reject(new BrioRequestError(message, frame.status ?? 500)); return; }