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:
Teknium
2026-08-18 11:51:50 -07:00
parent d758fdbce5
commit ce751ff584
4 changed files with 78 additions and 21 deletions
+13 -4
View File
@@ -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()), {})
})
+16 -11
View File
@@ -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')