From b236dca1b62773feffefc92fa3e69562d6a0fad2 Mon Sep 17 00:00:00 2001 From: finn763 <165816600+finn763@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:49:52 +0800 Subject: [PATCH] fix(desktop): keep pre-compression transcript reachable after in-place /compress (#105256) --- .../hooks/use-prompt-actions/index.test.tsx | 50 +++++++++++++++++++ .../session/hooks/use-prompt-actions/slash.ts | 11 +++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index f14ccc5e46..fdfadb5433 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -776,6 +776,56 @@ describe('usePromptActions /compress', () => { expect(renderedText).toContain('sure, here is the summary') }) + it('keeps pre-compression messages reachable after the transcript rewrite (#105256)', async () => { + const seeds: Record[] = [] + + const requestGateway = vi.fn(async (method: string, _params?: Record, _timeoutMs?: number) => { + if (method === 'session.compress') { + return { + removed: 2, + summary: { headline: 'Compressed: 4 → 2 messages' }, + messages: [ + { role: 'user', content: 'summarized context' }, + { role: 'assistant', content: 'sure, here is the summary' } + ] + } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + onSeedState={s => seeds.push(s)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + seedMessages={[ + { id: 'pre-1', role: 'user', parts: [textPart('pre-compression user turn')], timestamp: 0 }, + { id: 'pre-2', role: 'assistant', parts: [textPart('pre-compression assistant reply')], timestamp: 1 } + ]} + /> + ) + + await handle!.submitText('/compress') + + // The transcript was replaced with the post-compress history. + const finalMessages = seeds[seeds.length - 1]?.messages as Array<{ parts?: Array<{ text?: string }> }> + + const renderedText = (finalMessages ?? []) + .flatMap(message => (message.parts ?? []).map(part => part.text ?? '')) + .join('\n') + + expect(renderedText).toContain('summarized context') + expect(renderedText).toContain('sure, here is the summary') + // Active-session scrollback: the pre-compression segment stays in the + // transcript so scrolling up still reaches it. + expect(renderedText).toContain('pre-compression user turn') + expect(renderedText).toContain('pre-compression assistant reply') + expect(renderedText.indexOf('pre-compression user turn')).toBeLessThan(renderedText.indexOf('summarized context')) + }) + it('uses the compute-host response transcript and success output', async () => { const seeds: Record[] = [] diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 36e7b93ca1..7dbc3d8611 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -2,6 +2,7 @@ import { skillInvocationText } from '@hermes/shared' import { parseCommandDispatch, parseSlashCommand } from '@hermes/shared' import { type MutableRefObject, useCallback, useRef } from 'react' +import { mergeOlderTranscriptPage } from '@/app/chat/transcript-backfill' import { prepareDefaultNewSession } from '@/app/session/new-session-route' import { invalidateContextBreakdown } from '@/app/shell/hooks/use-context-breakdown' import { getProfiles } from '@/hermes' @@ -763,10 +764,18 @@ export function useSlashCommand(deps: SlashCommandDeps) { // toChatMessages handles it directly. updateSessionState only // publishes for the active runtime, guarding against a late result // clobbering the foreground after a session switch. + // The pre-compression segment stays reachable in the active + // session (#105256): the rewrite shares no anchor with the live + // transcript, so grafting would drop everything before the + // summary. Prepend it instead (deduped by row/message id), keeping + // the post-compress history authoritative for overlapping rows. if (Array.isArray(result?.messages)) { updateSessionState( sessionId, - state => ({ ...state, messages: toChatMessages(result.messages!) }), + state => ({ + ...state, + messages: mergeOlderTranscriptPage(toChatMessages(result.messages!), state.messages) + }), storedSessionId ) }