diff --git a/apps/desktop/src/api/sessions.ts b/apps/desktop/src/api/sessions.ts index 339449db11..002284af8c 100644 --- a/apps/desktop/src/api/sessions.ts +++ b/apps/desktop/src/api/sessions.ts @@ -459,9 +459,9 @@ export function getLatestSessionMessages( // (ambient, profile string, or explicit pin). Otherwise refreshes create // duplicate tail entries and "Show earlier" cannot resolve the loaded tail. // Capture before awaiting: the active gateway may change during the read. - const tailScope = { ...connectionScoped(), ...sessionScoped(profile) } + const route = { ...connectionScoped(), ...sessionScoped(profile) } // Normalize only the lookup key; backfill must replay the original route. - const ownerScope = { ...tailScope, connectionId: tailScope.connectionId || ambientOwnerConnectionId() } + const owner = { ...route, connectionId: route.connectionId || ambientOwnerConnectionId() } // includeCompacted: durable display history must include rows preserved by // in-place compaction (active=0, compacted=1); without them the transcript @@ -484,11 +484,11 @@ export function getLatestSessionMessages( // untagged read there lands on the ambient profile (`'default'` when // none is active). `null` is a custom HERMES_HOME and stays distinct. const servedProfile = page.profile === undefined ? getApiRequestProfile() || 'default' : page.profile - const resolvedOwnerScope = { ...ownerScope, profile: ownerScope.profile || servedProfile } - recordTranscriptTail(id, page, tailScope, resolvedOwnerScope) + const resolvedOwner = { ...owner, profile: owner.profile || servedProfile } + recordTranscriptTail(id, page, route, resolvedOwner) if (page.session_id && page.session_id !== id) { - recordTranscriptTail(page.session_id, page, tailScope, resolvedOwnerScope) + recordTranscriptTail(page.session_id, page, route, resolvedOwner) } return page diff --git a/apps/desktop/src/store/transcript-tail-cache.ts b/apps/desktop/src/store/transcript-tail-cache.ts index 04f13898a5..65619d19d2 100644 --- a/apps/desktop/src/store/transcript-tail-cache.ts +++ b/apps/desktop/src/store/transcript-tail-cache.ts @@ -23,8 +23,10 @@ import { withUniqueToolCallIdsWithinMessage } from '@/lib/chat-messages' // profile switches in the same window: an unscoped key let profile A's // tail be painted against profile B's backend, which then retried a // session id that does not exist there ("session not found") on every -// wake. The scope mirrors the in-memory twin's transcriptTailKey -// (transcript-tail.ts). Entries persisted before scoping shipped (v1, +// wake. The scope is the REQUEST scope the resume path passes (always a +// named profile); the in-memory twin (transcript-tail.ts) keys by the +// resolved owner instead and the two are never cross-read. Entries +// persisted before scoping shipped (v1, // bare-id keys) carry no owner and are unreachable by construction — // they are swept once per window so a stale tail can never paint again. diff --git a/apps/desktop/src/store/transcript-tail.ts b/apps/desktop/src/store/transcript-tail.ts index fec325dcdb..9e2443d2f9 100644 --- a/apps/desktop/src/store/transcript-tail.ts +++ b/apps/desktop/src/store/transcript-tail.ts @@ -23,8 +23,10 @@ export interface TranscriptTailState { /** The last hydration page was exactly the page limit, so older rows * likely exist beyond what the in-memory store holds. */ possiblyTruncated: boolean - /** Owning profile captured at hydration time, so a later backfill routes - * its REST read to the same backend that served the tail. */ + /** The request route captured at hydration time, replayed verbatim by a + * later backfill so it reaches the backend that served the tail. The + * resolved OWNER is the map key, not this field: an ambient read must stay + * ambient even once the server has named its profile. */ profile?: TranscriptProfileScope } @@ -43,6 +45,8 @@ let transcriptTailOrder: string[] = [] type TailPage = Pick function normalizedScope(profile?: TranscriptProfileScope): { connectionId: string; profile: string } | null { + // A bare string is the legacy "named profile" spelling: it always names a + // profile, so empty means the default one. if (typeof profile === 'string') { return { connectionId: '', profile: profile.trim() || 'default' } } @@ -115,21 +119,20 @@ function setTranscriptTailEntry(key: string, state: TranscriptTailState): void { $transcriptTailBySessionId.set(next) } -/** Record the outcome of a tail hydration (`getLatestSessionMessages`). */ +/** Record the outcome of a tail hydration (`getLatestSessionMessages`). + * `route` is what the request was sent with; `owner` is the resolved backend + * the entry is keyed under (defaults to the route when they coincide). */ export function recordTranscriptTail( storedSessionId: string, page: TailPage, - profile?: TranscriptProfileScope, - ownerScope: TranscriptProfileScope | undefined = profile + route?: TranscriptProfileScope, + owner: TranscriptProfileScope | undefined = route ): void { if (!storedSessionId) { return } - // Keep request routing separate from the resolved owner used for lookup: - // backfill must replay an ambient read even when the server names its profile. - const key = transcriptTailKey(storedSessionId, ownerScope) - setTranscriptTailEntry(key, tailStateFromPage(page, profile)) + setTranscriptTailEntry(transcriptTailKey(storedSessionId, owner), tailStateFromPage(page, route)) } /** Advance the bookkeeping after one older backfill page landed. */