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:
@@ -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')
|
||||
})
|
||||
})
|
||||
24
apps/desktop/src/app/contrib/hooks/remembered-session.ts
Normal file
24
apps/desktop/src/app/contrib/hooks/remembered-session.ts
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user