Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions apps/mobile/src/features/threads/hermes-thread-screen.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -113,10 +121,13 @@ export function HermesThreadScreen({
const router = useRouter();
const queryClient = useQueryClient();
const listRef = useRef<FlatList<FeedItem>>(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<ChatModelOverride | undefined>(
initialModelOverride,
);
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions apps/mobile/src/lib/brio.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import {
} from '../state/connection-store-model.ts';
import {
aggregateRootAgentUsage,
BrioRequestError,
brioFetch,
connectionFromPairingPayload,
createSession,
ensureSession,
decodePairingPayload,
extractPairingPayload,
filterAgentsForControlSession,
Expand All @@ -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' }),
Expand Down
62 changes: 60 additions & 2 deletions apps/mobile/src/lib/brio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -127,6 +137,11 @@ export type HermesSession = {
title?: string;
};

export type HermesSessionCreateResponse = {
object: 'hermes.session';
session: HermesSession;
};

export type HermesMessage = {
role: string;
content: string;
Expand Down Expand Up @@ -523,7 +538,10 @@ export async function brioFetch<T>(
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;
}
Expand Down Expand Up @@ -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<HermesSessionCreateResponse>(
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<HermesSessionCreateResponse>(
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,
Expand Down Expand Up @@ -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;
}

Expand Down
Loading