fix(desktop): never remember or restore a delegate subagent session

A delegate child (source='subagent') is invisible in the sidebar
(_LISTABLE_CHILD_SQL), so when its id lands in the remembered-session
slot the next cold start resumes an orphan chat while the sidebar
highlights the parent: every message silently goes to the child
(#56983). The remember path had no source check, and the restore path
validated ownership only — a child row that reached a list slice
(messaging aggregator, optimistic insert) passed both.

Guard both directions, keyed on source (not parent_session_id — /branch
children carry it too and ARE user-facing):

- Remember: a routed delegate row remembers its parent instead; an
  orphan child remembers nothing.
- Restore: a listed non-delegate row restores synchronously exactly as
  before; an unlisted id resolves by id (the by-id endpoint serves
  children the list omits), repairing a child to its parent, clearing
  an orphan/foreign id, and keeping the remembered value when the
  fetch itself fails.

Fixes #56983
Salvages #56988 (resolveRememberedSessionId + repair design, authored by baau)

Co-authored-by: baau <1347825413@qq.com>
This commit is contained in:
Hermes Agent
2026-09-25 12:28:34 -05:00
committed by brooklyn!
parent a00fdda6c5
commit 6231398b21
4 changed files with 196 additions and 12 deletions

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { resolveRememberedSessionId } from './remembered-session'
const session = (overrides: Partial<SessionInfo>): SessionInfo =>
({
ended_at: null,
id: 'session',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 0,
model: null,
output_tokens: 0,
preview: null,
source: 'tui',
started_at: 0,
title: null,
tool_call_count: 0,
...overrides
}) as SessionInfo
describe('resolveRememberedSessionId', () => {
it('repairs a remembered delegate child to its parent', async () => {
await expect(
resolveRememberedSessionId('child', async () =>
session({ id: 'child', parent_session_id: 'parent', source: 'subagent' })
)
).resolves.toBe('parent')
})
it('clears an orphaned delegate child instead of reopening it', async () => {
await expect(
resolveRememberedSessionId('child', async () => session({ id: 'child', source: 'subagent' }))
).resolves.toBeNull()
})
it('keeps normal sessions', async () => {
await expect(
resolveRememberedSessionId('normal', async () => session({ id: 'normal', source: 'tui' }))
).resolves.toBe('normal')
})
it('keeps /branch children: parenthood is not the discriminator, source is', async () => {
await expect(
resolveRememberedSessionId('branch', async () =>
session({ id: 'branch', parent_session_id: 'parent', source: 'tui' })
)
).resolves.toBe('branch')
})
})

View File

@@ -0,0 +1,24 @@
import type { SessionInfo } from '@/types/hermes'
/**
* Returns the session safe to remember/restore: a delegate child
* (`source === 'subagent'`) is replaced by its parent, everything else keeps
* its own id. Delegate children are deliberately omitted from the sidebar list
* (`_LISTABLE_CHILD_SQL`), so remembering one leaves the next cold start split
* between the highlighted parent and the invisible child the chat area shows
* (#56983). `/branch` children also carry `parent_session_id` but ARE
* user-facing — `source`, not parenthood, is the discriminator.
*
* Accepts metadata fetched directly by id (`getSession`): the by-id endpoint
* serves delegate children even though the list does not, so this repairs a
* stale child id without the sidebar list. `null` means "do not remember"
* (an orphaned delegate child whose parent is gone).
*/
export async function resolveRememberedSessionId(
id: string,
getSession: (id: string) => Promise<SessionInfo>
): Promise<null | string> {
const session = await getSession(id)
return session.source === 'subagent' ? session.parent_session_id ?? null : session.id
}

View File

@@ -82,7 +82,12 @@ describe('useDesktopIntegrations', () => {
onDeepLink: vi.fn(),
signalDeepLinkReady: vi.fn(),
onClosePreviewRequested: vi.fn(),
onOpenFolderRequested: vi.fn()
onOpenFolderRequested: vi.fn(),
// getSession() rides hermesDesktop.api; tests that exercise the
// remembered-session resolution stub this per-test.
api: vi.fn(async () => {
throw new Error('no api stub for this test')
})
} as unknown as Window['hermesDesktop']
})
@@ -265,6 +270,55 @@ describe('useDesktopIntegrations', () => {
})
})
describe('delegate subagent sessions', () => {
const stubGetSession = (row: Partial<SessionInfo>) => {
vi.mocked(desktopWindow.hermesDesktop!.api as ReturnType<typeof vi.fn>).mockImplementation(
async (request: { path?: string }) => {
if (request.path?.startsWith('/api/sessions/')) {
return session({ profile: 'default', ...row })
}
throw new Error(`unexpected api call: ${request.path}`)
}
)
}
it('repairs a remembered delegate child to its parent on restore', async () => {
// Written by an older build (or a list slice that served the child).
window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'delegate-child')
stubGetSession({ id: 'delegate-child', parent_session_id: 'parent-session', source: 'subagent' })
const sessions = [session({ id: 'parent-session', profile: 'default' })]
render({ profileReady: true, sessions })
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/parent-session', { replace: true }))
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('parent-session')
})
it('remembers the parent, never the delegate child, when routed to one', () => {
// A messaging slice can serve the child row, so list membership alone
// must not make it rememberable.
const sessions = [
session({ id: 'delegate-child', parent_session_id: 'parent-session', profile: 'default', source: 'subagent' })
]
render({ locationPathname: '/delegate-child', profileReady: true, routedSessionId: 'delegate-child', sessions })
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('parent-session')
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBe('/parent-session')
})
it('keeps remembering a /branch child: source, not parenthood, is the discriminator', () => {
const sessions = [session({ id: 'branch-child', parent_session_id: 'parent-session', profile: 'default', source: 'tui' })]
render({ locationPathname: '/branch-child', profileReady: true, routedSessionId: 'branch-child', sessions })
expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('branch-child')
expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBe('/branch-child')
})
})
describe('ownership validation', () => {
it('refuses to restore a session route owned by another profile', () => {
window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/ai-session')

View File

@@ -7,6 +7,7 @@ import { commandFocusedPreview } from '@/app/chat/right-rail/preview-nav'
import { openSession } from '@/app/open-session'
import { openConnectionDoneLink } from '@/components/assistant-ui/connector-tool'
import { $diskPluginsScanPending } from '@/contrib/runtime-loader'
import { getSession } from '@/hermes'
import { resolveDeepLinkAction } from '@/lib/deeplink-routes'
import { pathFromHermesDeepLink, resolveHermesOpenPath } from '@/lib/hermes-open-target'
import { storedSessionIdForNotification } from '@/lib/session-ids'
@@ -28,6 +29,7 @@ import {
getRememberedSessionId,
resolveComposerSessionKey,
sessionBelongsToProfile,
sessionMatchesStoredId,
setRememberedRoute,
setRememberedSessionId
} from '@/store/session'
@@ -41,7 +43,12 @@ import type { SessionInfo } from '@/types/hermes'
import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus'
import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from '../../routes'
type RememberedSession = Pick<SessionInfo, '_lineage_root_id' | 'id' | 'profile'>
import { resolveRememberedSessionId } from './remembered-session'
type RememberedSession = Pick<
SessionInfo,
'_lineage_root_id' | 'id' | 'parent_session_id' | 'profile' | 'source'
>
interface DesktopIntegrationsParams {
activeProfile: string
@@ -163,11 +170,22 @@ export function useDesktopIntegrations({
restoredRef.current = true
// A delegate child (source='subagent') is never a restorable
// destination: it is invisible in the sidebar, so resuming one leaves
// the app split between the highlighted parent and the child the chat
// area shows (#56983). `/branch` children also carry
// parent_session_id but ARE user-facing — source, not parenthood, is
// the discriminator. A listed row carries its source, so the guard is
// synchronous there; an unlisted id resolves by id below.
const rowFor = (id: string) => sessions.find(session => sessionMatchesStoredId(session, id))
const restorableRouteSession =
routeSession && rowFor(routeSession)?.source !== 'subagent' ? routeSession : null
if (
route &&
route !== NEW_CHAT_ROUTE &&
!isOverlayView(appViewForPath(route)) &&
(!routeSession || sessionBelongsToProfile(sessions, routeSession, activeProfile))
(!routeSession || (restorableRouteSession && sessionBelongsToProfile(sessions, routeSession, activeProfile)))
) {
// The user may have started typing on the fresh chat while the
// backend was still coming up; the composer moves that draft onto
@@ -184,16 +202,37 @@ export function useDesktopIntegrations({
setRememberedRoute(null, activeProfile)
}
if (last && sessionBelongsToProfile(sessions, last, activeProfile)) {
announceNewSessionDraftKey(resolveComposerSessionKey(last, sessions))
navigate(sessionRoute(last), { replace: true })
if (last) {
// Fast path: a listed, non-delegate row restores directly, exactly
// as before — no by-id fetch on the common cold start.
if (rowFor(last)?.source !== 'subagent' && sessionBelongsToProfile(sessions, last, activeProfile)) {
announceNewSessionDraftKey(resolveComposerSessionKey(last, sessions))
navigate(sessionRoute(last), { replace: true })
return
}
// Unlisted (or a delegate row that reached a list slice): resolve
// the id directly — the by-id endpoint serves delegate children the
// list omits. A delegate child repairs to its parent, an orphan or
// foreign-profile id clears, and a fetch failure keeps the
// remembered value for the next launch instead of discarding it.
void resolveRememberedSessionId(last, getSession)
.then(remembered => {
if (!remembered || !sessionBelongsToProfile(sessions, remembered, activeProfile)) {
setRememberedSessionId(null, activeProfile)
return
}
announceNewSessionDraftKey(resolveComposerSessionKey(remembered, sessions))
setRememberedSessionId(remembered, activeProfile)
navigate(sessionRoute(remembered), { replace: true })
})
.catch(() => undefined)
return
}
if (last) {
setRememberedSessionId(null, activeProfile)
}
} else {
restoredRef.current = true
}
@@ -204,8 +243,22 @@ export function useDesktopIntegrations({
// Session-shaped routes require an explicit matching owner; unresolved and
// wrong-profile rows must not replace known-safe navigation.
if (routedSessionId && sessionBelongsToProfile(sessions, routedSessionId, activeProfile)) {
setRememberedSessionId(routedSessionId, activeProfile)
setRememberedRoute(locationPathname, activeProfile)
// A delegate child (source='subagent') is never itself a rememberable
// destination: it is invisible in the sidebar, so a restart would resume
// an orphan chat while the sidebar highlights its parent (#56983).
// `/branch` children also carry parent_session_id but ARE user-facing —
// source, not parenthood, is the discriminator.
const routedRow = sessions.find(session => sessionMatchesStoredId(session, routedSessionId))
const rememberedSessionId =
routedRow?.source === 'subagent' ? routedRow.parent_session_id || null : routedSessionId
if (rememberedSessionId) {
setRememberedSessionId(rememberedSessionId, activeProfile)
setRememberedRoute(
rememberedSessionId === routedSessionId ? locationPathname : sessionRoute(rememberedSessionId),
activeProfile
)
}
} else if (!routedSessionId && !isOverlayView(appViewForPath(locationPathname))) {
setRememberedRoute(locationPathname, activeProfile)
}