Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface UseComposerQueueArgs {
onCancel: ChatBarProps['onCancel']
onSubmit: ChatBarProps['onSubmit']
queueEditRef: RefObject<QueueEditState | null>
queueProfile?: string | null
queueSessionKey: ChatBarProps['queueSessionKey']
sessionId: string | null | undefined
}
Expand All @@ -58,6 +59,7 @@ export function useComposerQueue({
onCancel,
onSubmit,
queueEditRef,
queueProfile,
queueSessionKey,
sessionId
}: UseComposerQueueArgs) {
Expand Down Expand Up @@ -171,7 +173,7 @@ export function useComposerQueue({
return false
}

if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments })) {
if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments, profile: queueProfile })) {
return false
}

Expand All @@ -180,7 +182,7 @@ export function useComposerQueue({
triggerHaptic('selection')

return true
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments])
}, [activeQueueSessionKey, attachments, clearDraft, draftRef, queueProfile, scope.attachments])

// All queue drain paths share one lock + send-then-remove sequence.
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
Expand All @@ -205,6 +207,7 @@ export function useComposerQueue({
onSubmit(entry.text, {
attachments: entry.attachments,
fromQueue: true,
...(entry.profile ? { profile: entry.profile } : {}),
sessionId: drainRuntimeSessionId,
storedSessionId: drainQueueSessionKey
})
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function ChatBar({
focusKey,
gateway,
maxRecordingSeconds = 120,
queueProfile,
queueSessionKey,
sessionId,
state,
Expand Down Expand Up @@ -203,6 +204,7 @@ export function ChatBar({
onCancel,
onSubmit,
queueEditRef,
queueProfile,
queueSessionKey,
sessionId
})
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/chat/composer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface ChatBarProps {
maxRecordingSeconds?: number
state: ChatBarState
gateway?: HermesGateway | null
queueProfile?: string | null
queueSessionKey?: string | null
sessionId?: string | null
cwd?: string | null
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/app/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ export function ChatView({
const messagesEmpty = useStore(view.$messagesEmpty)
const lastVisibleIsUser = useStore(view.$lastVisibleIsUser)
const selectedSessionId = useStore(view.$storedId)

const queueProfile = selectedSessionId
? ($sessions.get().find(session => sessionMatchesStoredId(session, selectedSessionId))?.profile ?? null)
: null

const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
// A tile IS its session — no route involved, never "mismatched".
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
Expand Down Expand Up @@ -524,6 +529,7 @@ export function ChatView({
onSteer={onSteer}
onSubmit={onSubmit}
onTranscribeAudio={onTranscribeAudio}
queueProfile={queueProfile}
queueSessionKey={selectedSessionId}
sessionId={activeSessionId}
state={chatBarState}
Expand Down
21 changes: 16 additions & 5 deletions apps/desktop/src/app/chat/session-tile-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
runRewindSubmit
} from '../session/hooks/use-prompt-actions/rewind'
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
import { type GatewayRequest, type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'

import type { ComposerScope } from './composer/scope'

Expand All @@ -54,7 +54,7 @@ interface SessionTileActionsArgs {
export function useSessionTileActions({ runtimeId, scope, storedSessionId }: SessionTileActionsArgs) {
const { t } = useI18n()
const copy = t.desktop
const { requestGateway } = useGatewayRequest()
const { requestGateway, requestGatewayForProfile } = useGatewayRequest()

const runtimeIdRef = useRef(runtimeId)
runtimeIdRef.current = runtimeId
Expand Down Expand Up @@ -95,9 +95,15 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
options: { profile?: string | null; requestGateway?: GatewayRequest; updateComposerAttachments?: boolean } = {}
): Promise<ComposerAttachment[]> => {
const remote = $connection.get()?.mode === 'remote'
const attachmentGateway = options.requestGateway ?? requestGateway

const profileConnection = options.profile
? await window.hermesDesktop?.getConnection(options.profile).catch(() => null)
: null

const remote = (profileConnection ?? $connection.get())?.mode === 'remote'
const synced: ComposerAttachment[] = []

for (const attachment of attachments) {
Expand All @@ -108,7 +114,11 @@ 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, {
remote,
requestGateway: attachmentGateway,
sessionId
})

if (options.updateComposerAttachments ?? true) {
scope.attachments.update(next)
Expand Down Expand Up @@ -140,6 +150,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
// token is a stable constant (the guard never trips for a tile).
getRouteToken: () => runtimeId,
requestGateway,
requestGatewayForProfile,
// Tile ids are always bound before this hook mounts, so routed recovery is
// unreachable here; keep the shared submit contract explicit.
resumeStoredSession: () => undefined,
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
setMessages
})

const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest()
const { connectionRef, gatewayRef, requestGateway, requestGatewayForProfile } = useGatewayRequest()

const {
loadMoreMessagingForPlatform,
Expand Down Expand Up @@ -530,6 +530,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
openMemoryGraph: openStarmap,
refreshSessions,
requestGateway,
requestGatewayForProfile,
resumeStoredSession: resumeSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
Expand All @@ -539,8 +540,17 @@ export function ContribWiring({ children }: { children: ReactNode }) {

// Runs outside the selected ChatBar so queues belonging to background
// sessions continue once those sessions are idle.
const getProfileForStoredSession = useCallback((storedSessionId: string): null | string => {
const stored = [...$sessions.get(), ...$messagingSessions.get()].find(session =>
sessionMatchesStoredId(session, storedSessionId)
)

return stored?.profile ?? null
}, [])

useBackgroundQueueDrain({
enabled: gatewayState === 'open',
getProfileForStoredSession,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
submitText
Expand Down
17 changes: 15 additions & 2 deletions apps/desktop/src/app/gateway/hooks/use-gateway-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef } from 'react'

import type { HermesGateway } from '@/hermes'
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
import { $gateway, ensureActiveGatewayOpen, ensureGatewayOpenForProfile, isActivePrimary } from '@/store/gateway'
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState, setConnection } from '@/store/session'

Expand Down Expand Up @@ -134,5 +134,18 @@ export function useGatewayRequest() {
[ensureGatewayOpen]
)

return { connectionRef, gatewayRef, requestGateway }
const requestGatewayForProfile = useCallback(
async <T>(profile: string, method: string, params: Record<string, unknown> = {}, timeoutMs?: number) => {
const gateway = await ensureGatewayOpenForProfile(profile)

if (!gateway) {
throw new Error(`Hermes gateway unavailable for profile: ${profile}`)
}

return gateway.request<T>(method, params, timeoutMs)
},
[]
)

return { connectionRef, gatewayRef, requestGateway, requestGatewayForProfile }
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ import type { SubmitTextOptions } from './use-prompt-actions/utils'

function Harness({
enabled = true,
getProfileForStoredSession,
runtimeMap,
selectedStoredSessionId = 'stored-session-b',
submitText
}: {
enabled?: boolean
getProfileForStoredSession?: (storedSessionId: string) => null | string
runtimeMap: MutableRefObject<Map<string, string>>
selectedStoredSessionId?: string | null
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
}) {
useBackgroundQueueDrain({
enabled,
getProfileForStoredSession,
runtimeIdByStoredSessionIdRef: runtimeMap,
selectedStoredSessionId,
submitText
Expand Down Expand Up @@ -100,14 +103,25 @@ describe('useBackgroundQueueDrain', () => {
const runtimeMap = { current: new Map<string, string>() }
const submitText = vi.fn(async () => true)

enqueueQueuedPrompt('stored-session-a', { text: 'resume then send', attachments: [] })
enqueueQueuedPrompt('stored-session-a', {
text: 'resume then send',
attachments: [],
profile: 'persisted-background-profile'
})

render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
render(
<Harness
getProfileForStoredSession={() => 'stale-fallback-profile'}
runtimeMap={runtimeMap}
submitText={submitText}
/>
)

await waitFor(() => {
expect(submitText).toHaveBeenCalledWith('resume then send', {
attachments: [],
fromQueue: true,
profile: 'persisted-background-profile',
sessionId: null,
storedSessionId: 'stored-session-a'
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type SubmitQueuedPrompt = (text: string, options?: SubmitTextOptions) => Promise

interface BackgroundQueueDrainOptions {
enabled: boolean
getProfileForStoredSession?: (storedSessionId: string) => null | string
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null
submitText: SubmitQueuedPrompt
Expand All @@ -37,6 +38,7 @@ const BACKGROUND_DRAIN_RETRY_MS = 750
*/
export function useBackgroundQueueDrain({
enabled,
getProfileForStoredSession,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
submitText
Expand Down Expand Up @@ -113,11 +115,13 @@ export function useBackgroundQueueDrain({
}

const runtimeSessionId = runtimeIdByStoredSessionIdRef.current.get(sessionKey) ?? null
const profile = liveEntry.profile?.trim() || getProfileForStoredSession?.(sessionKey)?.trim() || null

const accepted = await Promise.resolve(
submitTextRef.current(liveEntry.text, {
attachments: liveEntry.attachments,
fromQueue: true,
...(profile ? { profile } : {}),
sessionId: runtimeSessionId,
storedSessionId: sessionKey
})
Expand All @@ -143,7 +147,7 @@ export function useBackgroundQueueDrain({
drainingSessionIdsRef.current.delete(sessionKey)
})
},
[runtimeIdByStoredSessionIdRef, scheduleRetry, t]
[getProfileForStoredSession, runtimeIdByStoredSessionIdRef, scheduleRetry, t]
)

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ function Harness({
openMemoryGraph,
refreshSessions,
requestGateway,
requestGatewayForProfile,
resumeStoredSession,
seedMessages,
selectedStoredSessionIdRef: selectedStoredSessionIdRefProp,
Expand All @@ -99,6 +100,12 @@ function Harness({
openMemoryGraph?: () => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
requestGatewayForProfile?: <T>(
profile: string,
method: string,
params?: Record<string, unknown>,
timeoutMs?: number
) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
seedMessages?: unknown[]
selectedStoredSessionIdRef?: MutableRefObject<string | null>
Expand Down Expand Up @@ -138,6 +145,7 @@ function Harness({
openMemoryGraph: openMemoryGraph ?? (() => undefined),
refreshSessions,
requestGateway,
requestGatewayForProfile: requestGatewayForProfile ?? ((_, method, params) => requestGateway(method, params)),
resumeStoredSession: resumeStoredSession ?? (() => undefined),
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
Expand Down Expand Up @@ -601,6 +609,71 @@ describe('usePromptActions submit / queue drain semantics', () => {
expect($busy.get()).toBe(false)
})

it('a background queue drain with no runtime id resumes its stored session instead of using the foreground runtime', async () => {
$busy.set(false)

const calls: { method: string; params?: Record<string, unknown>; profile: string }[] = []
const recoveredBackgroundRuntimeId = 'rt-background-recovered'
const backgroundStoredSessionId = 'stored-background'
const backgroundProfile = 'background-profile'
const foregroundStoredSessionId = 'stored-foreground'
const resumeStoredSession = vi.fn()

const requestGateway = vi.fn(async () => {
throw new Error('foreground gateway must not serve a background-profile queue')
})

const requestGatewayForProfile = vi.fn(
async (profile: string, method: string, params?: Record<string, unknown>) => {
calls.push({ method, params, profile })

if (method === 'session.resume') {
return { session_id: recoveredBackgroundRuntimeId } as never
}

return {} as never
}
)

let handle: HarnessHandle | null = null
await actRender(
<Harness
getRoutedStoredSessionId={() => foregroundStoredSessionId}
getRuntimeIdForStoredSession={() => null}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
requestGatewayForProfile={requestGatewayForProfile}
resumeStoredSession={resumeStoredSession}
storedSessionId={foregroundStoredSessionId}
/>
)

const accepted = await handle!.submitText('resume queued background session', {
fromQueue: true,
profile: backgroundProfile,
sessionId: null,
storedSessionId: backgroundStoredSessionId
})

expect(accepted).toBe(true)
expect(calls).toEqual([
{
method: 'session.resume',
params: { session_id: backgroundStoredSessionId, source: 'desktop' },
profile: backgroundProfile
},
{
method: 'prompt.submit',
params: { session_id: recoveredBackgroundRuntimeId, text: 'resume queued background session' },
profile: backgroundProfile
}
])
expect(requestGateway).not.toHaveBeenCalled()
expect(resumeStoredSession).not.toHaveBeenCalled()
expect(handle!.activeSessionIdRef.current).toBe(RUNTIME_SESSION_ID)
})

it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => {
// A stale-session 404 must not strand the queued entry: submitPrompt returns
// false on failure so the composer keeps it, and the edge-independent
Expand Down
Loading