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
1 change: 1 addition & 0 deletions apps/desktop/electron/connection-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,7 @@ test('roster: unique profiles keep bare handles; duplicates get @name-device', (
test('roster: source profile metadata follows the connection-qualified row', () => {
const local = { id: 'local', kind: 'local' as const, label: 'This device' }
const vps = { id: 'vps', kind: 'remote' as const, label: 'VPS', url: 'http://vps:8642' }

const vpsMeta = {
display_name: 'Emma',
ui_meta: { 'hermes-bots': { title: 'Emma', shape: 'blobatar::sun', color: '#8b5cf6' } },
Expand Down
10 changes: 2 additions & 8 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9542,9 +9542,7 @@ async function teardownSshConnection(profile) {
// connectWindowsRemote does not share POSIX lock/kill. Stay
// silent on the kill path, but leave a log so quit is not a
// mysterious no-op on Windows remotes.
sshRememberLog(
'[ssh] skip remote serve teardown on Windows remotes; POSIX disconnect does not apply'
)
sshRememberLog('[ssh] skip remote serve teardown on Windows remotes; POSIX disconnect does not apply')
}
: remoteLifecycle.disconnect
}
Expand Down Expand Up @@ -14099,11 +14097,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>

return { ok: true, baseUrl, connected: true }
} catch (error) {
rememberLog(
`[native-oauth] native login failed (${
error instanceof Error ? error.message : String(error)
})`
)
rememberLog(`[native-oauth] native login failed (${error instanceof Error ? error.message : String(error)})`)

return { ok: false, error: error instanceof Error ? error.message : String(error), connected: false }
}
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/electron/native-auth-decisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ test('oauthGuardMayHardFail keeps the strict guard when the list is unusable', (
assert.equal(oauthGuardMayHardFail([{ supportsPassword: true }]), true)
})


test('oauthGuardMayHardFail treats status-shaped string basic as password-only', () => {
assert.equal(oauthGuardMayHardFail(['basic'] as any), false)
assert.equal(oauthGuardMayHardFail([' basic '] as any), false)
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/native-auth-decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export function normalizeAdvertisedAuthProviders(providers: unknown): Advertised
}

out.push({ name, supportsPassword: PASSWORD_PROVIDER_NAMES.has(name) })

continue
}

Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/electron/plugin-profile-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,9 @@ describe('localRouteFallbackProfiles', () => {
})

it('synthesizes local routes for a genuine local enumeration error', () => {
expect(
localRouteFallbackProfiles([], 'local', ['default'], isLocalEnumerationFailure('ECONNREFUSED'))
).toEqual(['default'])
expect(localRouteFallbackProfiles([], 'local', ['default'], isLocalEnumerationFailure('ECONNREFUSED'))).toEqual([
'default'
])
})
})

Expand Down
8 changes: 2 additions & 6 deletions apps/desktop/electron/remote-liveness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ export class RemoteRevalidationCoordinator {
}
}

interface EnsureHealthyPooledRemoteBackendForDispatchOptions<
TConnection extends RemoteConnectionDescriptor
> {
interface EnsureHealthyPooledRemoteBackendForDispatchOptions<TConnection extends RemoteConnectionDescriptor> {
connectionPromise: Promise<TConnection>
currentConnectionPromise: () => null | Promise<TConnection>
probe: (connection: TConnection, path: string, options: { timeoutMs: number }) => Promise<unknown>
Expand All @@ -86,9 +84,7 @@ interface EnsureHealthyPooledRemoteBackendForDispatchOptions<
* caller. The caller should single-flight this function per cached promise so
* concurrent dispatches share one retire/reconnect sequence.
*/
export async function ensureHealthyPooledRemoteBackendForDispatch<
TConnection extends RemoteConnectionDescriptor
>({
export async function ensureHealthyPooledRemoteBackendForDispatch<TConnection extends RemoteConnectionDescriptor>({
connectionPromise,
currentConnectionPromise,
probe,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/chat/session-tile-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function listTileSessionRow(deps: {

const knownOwner =
sessionTileOwnerRoute(deps.storedSessionId) ?? knownSessionOwner(deps.sessions, deps.storedSessionId)

const ownerRoute: SessionProfileRoute | undefined =
knownOwner && typeof knownOwner === 'object' ? knownOwner : undefined

Expand Down Expand Up @@ -178,6 +179,7 @@ export function useSessionTileActions({ requestGateway, runtimeId, scope, stored
<T>(method: string, params?: Record<string, unknown>, timeoutMs?: number, signal?: AbortSignal) => {
const knownOwner: SessionOwnerScope =
sessionTileOwnerRoute(storedIdRef.current) ?? knownSessionOwner($sessions.get(), storedIdRef.current)

// A bare profile is the legacy/unknown tile shape. Preserve its ambient
// behavior; only a composite route is strong enough to retarget a tile
// across same-named sources.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/chat/sidebar/session-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ describe('resolvePinnedSessions', () => {
row('foreign', { last_active: 1, pinned: true, profile: 'k9' }),
row('local', { last_active: 50, pinned: true, profile: 'default' })
]

const index = buildSessionByAnyId(sessions, [], [])

expect(resolvePinnedSessions(['foreign', 'local'], index, sessions, settled).map(s => s.id)).toEqual([
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
selectConnection,
setConnectionsRegistry
} from '@/store/connections'
import { activeGateway, closeSecondaryGateways, ensureGatewayForAgent, isActivePrimary, requestGatewayForAgent } from '@/store/gateway'
import {
activeGateway,
closeSecondaryGateways,
ensureGatewayForAgent,
isActivePrimary,
requestGatewayForAgent
} from '@/store/gateway'
import { reconnectGateway } from '@/store/gateway-reconnect'
import {
$gatewaySwitching,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ export function useGatewayBoot({

const offEvent = gateway.onEvent(event => {
const connectionId = activeGatewayConnectionId()

const scopedEvent = {
...event,
profile: sourceProfile,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const remoteConnection = {

function installRemoteDesktop() {
let mintCount = 0

const getConnection = vi.fn(async (profile?: null | string) => ({
authMode: 'token' as const,
baseUrl: 'http://127.0.0.1:5151',
Expand All @@ -95,15 +96,18 @@ function installRemoteDesktop() {
token: 'local-token',
wsUrl: 'ws://127.0.0.1:5151/api/ws?token=local'
}))

const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
...remoteConnection,
connectionId,
profile
}))

const getGatewayWsUrl = vi.fn(async () => ({
ok: true as const,
wsUrl: 'ws://127.0.0.1:5151/api/ws?token=fresh-local'
}))

const getGatewayWsUrlFor = vi.fn(
async ({ connectionId, profile }: { connectionId: string; profile: string }): Promise<GatewayWsUrlResult> => {
mintCount += 1
Expand Down Expand Up @@ -132,13 +136,15 @@ function installPrimaryDesktop(authMode: 'oauth' | 'token') {
token: 'primary-token',
wsUrl: authMode === 'oauth' ? 'wss://gateway.example.test/api/ws?ticket=stale' : 'ws://127.0.0.1:5151/api/ws'
}))

const getGatewayWsUrl = vi.fn(async (profile?: null | string) => ({
ok: true as const,
wsUrl:
authMode === 'oauth'
? `wss://gateway.example.test/api/ws?profile=${profile ?? 'default'}&ticket=fresh`
: 'ws://127.0.0.1:5151/api/ws?token=fresh'
}))

const getConnectionFor = vi.fn()
const getGatewayWsUrlFor = vi.fn()

Expand Down Expand Up @@ -183,6 +189,7 @@ async function expectSecondaryRecoveryFailure(
gateway.connectionState = 'closed'

vi.useFakeTimers()

const retry = request('session.resume').then(
() => undefined,
error => error
Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/src/app/shell/model-catalog-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,7 @@ export function ModelCatalogMenu({
// Gateway-first even with no session: a connected (possibly remote)
// gateway owns the model catalog, including virtual providers the local
// REST fallback can't know about (#53817).
queryFn: (): Promise<ModelOptionsResponse> =>
requestModelOptions({ gateway, profile, request, sessionId })
queryFn: (): Promise<ModelOptionsResponse> => requestModelOptions({ gateway, profile, request, sessionId })
})

const loading = modelOptions.isPending && !modelOptions.data
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/connection-scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function connectionScopedAtom<T>(
options?: ConnectionScopeOptions
): WritableAtom<T> {
const includeProfile = options?.includeProfile !== false

const entry: ScopedEntry<T> = {
$value: atom<T>(fallback),
applying: false,
Expand All @@ -140,6 +141,7 @@ export function connectionScopedAtom<T>(
key,
suffix: connectionScopeSuffix(activeConnection, includeProfile)
}

entry.$value.set(loadEntry(entry))
registry.push(entry)

Expand Down
14 changes: 8 additions & 6 deletions apps/desktop/src/lib/model-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,22 +124,25 @@ describe('requestModelOptions', () => {
provider: 'nous',
providers: [{ models: ['chrome-model'], name: 'Nous', slug: 'nous' }]
}

const routedPayload = {
model: 'berry-model',
provider: 'openai',
providers: [{ models: ['berry-model'], name: 'OpenAI', slug: 'openai' }]
}

const gateway = {
request: vi.fn(() => Promise.resolve(gatewayPayload))
}

const request = vi.fn(() => Promise.resolve(routedPayload)) as unknown as <T>(
method: string,
params?: Record<string, unknown>
) => Promise<T>

await expect(
requestModelOptions({ gateway: gateway as never, request, sessionId: 'tile-1' })
).resolves.toBe(routedPayload)
await expect(requestModelOptions({ gateway: gateway as never, request, sessionId: 'tile-1' })).resolves.toBe(
routedPayload
)

expect(request).toHaveBeenCalledWith('model.options', { explicit_only: true, session_id: 'tile-1' })
expect(gateway.request).not.toHaveBeenCalled()
Expand All @@ -151,13 +154,12 @@ describe('requestModelOptions', () => {
provider: 'hermes-local',
providers: [{ models: ['berry-local'], name: 'Hermes Local', slug: 'hermes-local' }]
}

const request = vi.fn(() => Promise.reject(new Error('gateway request unavailable')))

vi.mocked(getGlobalModelOptions).mockResolvedValueOnce(restPayload)

await expect(requestModelOptions({ profile: 'berry', request, sessionId: 'tile-1' })).resolves.toEqual(
restPayload
)
await expect(requestModelOptions({ profile: 'berry', request, sessionId: 'tile-1' })).resolves.toEqual(restPayload)
expect(getGlobalModelOptions).toHaveBeenCalledWith({ explicitOnly: true }, 'berry')
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'

const gatewayState = atom<'closed' | 'open'>('closed')

const listPersistedSessions = vi.fn(
async (_route: unknown, options: { profile: string }) => ({
sessions: [{ id: `${options.profile}-bot`, profile: options.profile, started_at: 1, title: 'Bot Chat' }]
})
)
const listPersistedSessions = vi.fn(async (_route: unknown, options: { profile: string }) => ({
sessions: [{ id: `${options.profile}-bot`, profile: options.profile, started_at: 1, title: 'Bot Chat' }]
}))

const setPersistedSessionHidden = vi.fn(
async (_route: unknown, _options: { hidden: boolean; profile: string; sessionId: string }) => ({ hidden: true, ok: true })
async (_route: unknown, _options: { hidden: boolean; profile: string; sessionId: string }) => ({
hidden: true,
ok: true
})
)

const request = vi.fn(async (method: string) =>
Expand Down Expand Up @@ -86,7 +87,9 @@ describe('Bot Mode hidden-session reconciliation lifecycle', () => {
expect.objectContaining({ hidden: true, profile: 'beta', sessionId: 'beta-bot' })
])
)
expect(request.mock.calls.some(([method]) => method === 'session.list' || method === 'session.set_hidden')).toBe(false)
expect(request.mock.calls.some(([method]) => method === 'session.list' || method === 'session.set_hidden')).toBe(
false
)

disposers.forEach(dispose => dispose())
const readsAtDispose = listPersistedSessions.mock.calls.length
Expand All @@ -97,4 +100,4 @@ describe('Bot Mode hidden-session reconciliation lifecycle', () => {

expect(listPersistedSessions).toHaveBeenCalledTimes(readsAtDispose)
})
})
})
3 changes: 2 additions & 1 deletion apps/desktop/src/store/gateway-connection-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const {
setPrimaryGateway,
setPrimaryGatewayConnectionId
} = await import('./gateway')

const { setApiRequestConnection } = await import('@/hermes')

function installDesktop(): void {
Expand Down Expand Up @@ -160,7 +161,7 @@ describe('pruneSecondaryGateways with registry-scoped entries', () => {
expect(gatewayMocks.closed).toHaveLength(1)
})

it("does not let a remote tile keep-set pin a local same-named secondary", async () => {
it('does not let a remote tile keep-set pin a local same-named secondary', async () => {
// Chrome is on another profile so 'default' is a real secondary, not the
// spared active key. A homelab bot tile keep-set must keep only the
// composite scope — the local 'default' socket still idles out.
Expand Down
9 changes: 2 additions & 7 deletions apps/desktop/src/store/gateway-shared-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,8 @@ vi.mock('@/store/session', () => ({
}))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))

const {
$gateway,
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForProfile,
setPrimaryGateway
} = await import('./gateway')
const { $gateway, closeSecondaryGateways, configureGatewayRegistry, ensureGatewayForProfile, setPrimaryGateway } =
await import('./gateway')

type DesktopStub = { getConnection: ReturnType<typeof vi.fn> }

Expand Down
4 changes: 1 addition & 3 deletions apps/desktop/src/store/session-states.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,7 @@ describe('foregroundSessionScopes', () => {
}
])

expect(foregroundSessionScopes()).toEqual(
new Set(['conn:cloud-a::default', 'conn:cloud-b::default'])
)
expect(foregroundSessionScopes()).toEqual(new Set(['conn:cloud-a::default', 'conn:cloud-b::default']))
})

it('releases an idle pane owner when the pane closes', () => {
Expand Down
Loading