fix(desktop): resume cold-opened Bot Chats even before selection settles (#89206 class)
The salvaged gate only requested an explicit session.resume when the target stored session was ALREADY selected — but the field failure (#89206) is the cold open, where the persisted route points at the bot's session while selection/runtime/transcript are all unsettled. The precondition skipped the resume exactly when it was needed, and the hydration wait timed out into a blank pane. - sdk/index.ts: judge the main surface AFTER openSession() navigates, and request a sequenced resume whenever the surface is not healthy (selected + runtime bound + expected transcript present). Redundant requests are consumed as no-ops by the route-resume effect. - hermes-bots plugin: widen the fix to the sibling open path — the profile session browser (openProfileSession) now opens with the same awaitHydration/expectHistory contract as canonical Bot Chats, so a stale main surface gets the same explicit resume instead of a silent blank pane. - Regression test for the cold-open shape, proven failing against the pre-fix gate (sabotage run) and passing with it.
This commit is contained in:
@@ -7289,14 +7289,23 @@ function useProfileSessions(botName, gatewayGeneration) {
|
||||
})
|
||||
}
|
||||
|
||||
async function openProfileSession(botName, storedId, gatewayGeneration) {
|
||||
async function openProfileSession(botName, session, gatewayGeneration) {
|
||||
const profile = String(botName || '')
|
||||
const id = String(storedId || '')
|
||||
const id = String(session?.id || '')
|
||||
if (!NAME_RE.test(profile) || !id || gatewayGeneration !== $sessionsGatewayGeneration.get()) return
|
||||
if (typeof host.openSession !== 'function') {
|
||||
throw new Error('This Hermes Desktop version cannot open stored sessions')
|
||||
}
|
||||
await host.openSession(id, { profile })
|
||||
|
||||
// Same hydration contract as canonical Bot Chats (#89206): a bare open can
|
||||
// focus a main surface whose runtime/transcript silently vanished, leaving a
|
||||
// blank pane while the row preview still shows the conversation. Waiting on
|
||||
// hydration lets the SDK issue the explicit resume when the surface is stale.
|
||||
const hasAuthoritativeCount =
|
||||
typeof session?.message_count === 'number' && Number.isFinite(session.message_count)
|
||||
const expectHistory = hasAuthoritativeCount ? session.message_count > 0 : Boolean(session?.preview)
|
||||
|
||||
await host.openSession(id, { profile, awaitHydration: true, expectHistory })
|
||||
if (gatewayGeneration !== $sessionsGatewayGeneration.get()) return
|
||||
$botSelectedSessions.set({ ...$botSelectedSessions.get(), [profile]: id })
|
||||
}
|
||||
@@ -7305,7 +7314,7 @@ function ProfileSessionRow({ session, botName, active, gatewayGeneration }) {
|
||||
return jsxs('button', {
|
||||
type: 'button',
|
||||
'aria-current': active ? 'page' : undefined,
|
||||
onClick: () => void openProfileSession(botName, session.id, gatewayGeneration).catch(err => host.notifyError(err, 'Could not open session')),
|
||||
onClick: () => void openProfileSession(botName, session, gatewayGeneration).catch(err => host.notifyError(err, 'Could not open session')),
|
||||
className: cn(
|
||||
'flex w-full flex-col gap-0.5 overflow-hidden rounded-md px-2 py-1.5 text-left transition-colors',
|
||||
'hover:bg-(--chrome-action-hover)',
|
||||
|
||||
@@ -88,15 +88,17 @@ test('sessions workspace: filtering searches title, preview, and source without
|
||||
|
||||
test('sessions workspace: opening a stored row uses profile-aware navigation and records selection', async () => {
|
||||
const runtime = load({ profile: 'default' })
|
||||
await runtime.__sessions.openProfileSession('ops', 'stored-123', 0)
|
||||
assert.deepEqual(plain(runtime.calls), [['openSession', 'stored-123', { profile: 'ops' }]])
|
||||
await runtime.__sessions.openProfileSession('ops', { id: 'stored-123', message_count: 4 }, 0)
|
||||
assert.deepEqual(plain(runtime.calls), [
|
||||
['openSession', 'stored-123', { profile: 'ops', awaitHydration: true, expectHistory: true }]
|
||||
])
|
||||
assert.equal(runtime.__sessions.$botSelectedSessions.get().ops, 'stored-123')
|
||||
})
|
||||
|
||||
test('sessions workspace: malformed profile or session input is a no-op', async () => {
|
||||
const runtime = load()
|
||||
await runtime.__sessions.openProfileSession('../ops', 'stored-123', 0)
|
||||
await runtime.__sessions.openProfileSession('ops', '', 0)
|
||||
await runtime.__sessions.openProfileSession('../ops', { id: 'stored-123' }, 0)
|
||||
await runtime.__sessions.openProfileSession('ops', { id: '' }, 0)
|
||||
assert.deepEqual(runtime.calls, [])
|
||||
})
|
||||
|
||||
@@ -107,17 +109,25 @@ test('sessions workspace: a gateway lifecycle change clears selection and reject
|
||||
assert.equal(runtime.__sessions.$sessionsGatewayGeneration.get(), 1)
|
||||
assert.deepEqual(plain(runtime.__sessions.$botSelectedSessions.get()), {})
|
||||
|
||||
await runtime.__sessions.openProfileSession('ops', 'stored-123', 0)
|
||||
await runtime.__sessions.openProfileSession('ops', { id: 'stored-123' }, 0)
|
||||
assert.deepEqual(runtime.calls, [])
|
||||
})
|
||||
|
||||
test('sessions workspace: an empty session with no preview does not demand history', async () => {
|
||||
const runtime = load()
|
||||
await runtime.__sessions.openProfileSession('ops', { id: 'stored-empty', message_count: 0 }, 0)
|
||||
assert.deepEqual(plain(runtime.calls), [
|
||||
['openSession', 'stored-empty', { profile: 'ops', awaitHydration: true, expectHistory: false }]
|
||||
])
|
||||
})
|
||||
|
||||
test('sessions workspace: an in-flight open cannot restore selection after gateway replacement', async () => {
|
||||
let runtime
|
||||
runtime = load({
|
||||
openSession: async () => runtime.__sessions.handleSessionsGatewayTransition()
|
||||
})
|
||||
|
||||
await runtime.__sessions.openProfileSession('ops', 'stored-123', 0)
|
||||
await runtime.__sessions.openProfileSession('ops', { id: 'stored-123', preview: 'last line' }, 0)
|
||||
assert.equal(runtime.calls.length, 1)
|
||||
assert.deepEqual(plain(runtime.__sessions.$botSelectedSessions.get()), {})
|
||||
})
|
||||
|
||||
@@ -486,13 +486,6 @@ export const host = {
|
||||
$gatewaySwapTarget.set(targetProfile)
|
||||
}
|
||||
|
||||
const mainMessages = $messages.get()
|
||||
|
||||
const needsExplicitResume =
|
||||
options.awaitHydration &&
|
||||
$selectedStoredSessionId.get() === storedSessionId &&
|
||||
(!$activeSessionId.get() || (expectHistory && !mainMessages.length))
|
||||
|
||||
try {
|
||||
openSession(
|
||||
storedSessionId,
|
||||
@@ -508,10 +501,22 @@ export const host = {
|
||||
options.intent ?? 'in-place'
|
||||
)
|
||||
|
||||
if (needsExplicitResume) {
|
||||
// Re-selecting a routed Bot Chat normally focuses the existing main
|
||||
// surface without changing the URL. If that surface lost its runtime or
|
||||
// transcript, the route effect otherwise gets no signal to re-resume it.
|
||||
// Judge the main surface AFTER the open: on a cold start the persisted
|
||||
// route can already point at this session while selection has not
|
||||
// settled, so a pre-open "already selected" precondition skips the
|
||||
// resume exactly when it is needed (#89206 — blank Bot Chat with the
|
||||
// roster preview intact). The surface is healthy only when this stored
|
||||
// session is selected, a runtime is bound, and the expected transcript
|
||||
// is present; anything less gets an explicit sequenced resume request.
|
||||
// The route-resume effect only honors the request while the route
|
||||
// points at this session, and consumes it alongside any resume the
|
||||
// navigation itself triggers, so a redundant request is a no-op.
|
||||
const surfaceHealthy =
|
||||
$selectedStoredSessionId.get() === storedSessionId &&
|
||||
Boolean($activeSessionId.get()) &&
|
||||
(!expectHistory || $messages.get().length > 0)
|
||||
|
||||
if (options.awaitHydration && !surfaceHealthy) {
|
||||
requestSessionResume(storedSessionId)
|
||||
}
|
||||
|
||||
|
||||
@@ -345,6 +345,39 @@ describe('profile-aware plugin session opens', () => {
|
||||
expect($gatewaySwapTarget.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('requests an explicit resume on a cold open where selection has not settled (#89206)', async () => {
|
||||
vi.mocked(ensureGatewayProfile).mockImplementationOnce(async (target: null | string | undefined) => {
|
||||
$activeGatewayProfile.set(target || 'default')
|
||||
})
|
||||
|
||||
// Cold-start shape from the field: the persisted route already points at
|
||||
// the bot's stored session, but no selection, no runtime, no transcript.
|
||||
setMockAtom($selectedStoredSessionId, null)
|
||||
setMockAtom($activeSessionId, null)
|
||||
setMockAtom($messages, [])
|
||||
|
||||
const opening = host.openSession('cold-bot-chat', {
|
||||
profile: 'hyoseob',
|
||||
awaitHydration: true,
|
||||
expectHistory: true,
|
||||
hydrationTimeoutMs: 1_000
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// The old pre-open "already selected" precondition skipped this request,
|
||||
// stranding the pane blank until timeout. It must fire now.
|
||||
expect(requestSessionResume).toHaveBeenCalledWith('cold-bot-chat')
|
||||
|
||||
setMockAtom($selectedStoredSessionId, 'cold-bot-chat')
|
||||
setMockAtom($activeSessionId, 'runtime-cold')
|
||||
setMockAtom($messages, [{ id: 'history-cold', parts: [], role: 'assistant' }] as never)
|
||||
|
||||
await opening
|
||||
expect($gatewaySwapTarget.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('requests a fresh resume when the same main Bot Chat lost its transcript', async () => {
|
||||
$activeGatewayProfile.set('hyoseob')
|
||||
setMockAtom($selectedStoredSessionId, 'bot-chat')
|
||||
|
||||
Reference in New Issue
Block a user