diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 20980fdf2a25..1dabc674d043 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -141,22 +141,29 @@ export function createSandbox(prefix: string): Sandbox { * mock inference server. The provider is set as the active model provider so * the desktop app skips onboarding and boots straight to the chat UI. * - * @param extraConfig optional YAML lines appended to the `display:` section, - * used by the interim-message e2e test to toggle - * `display.interim_assistant_messages`. + * @param extraDisplayConfig optional YAML lines appended to the `display:` + * section, used by the interim-message e2e test. + * @param extraConfig optional top-level YAML sections for a test scenario. + * @param modelContextLength optional primary-model context limit. */ -export function writeMockProviderConfig(hermesHome: string, mockUrl: string, extraConfig?: string): void { +export function writeMockProviderConfig( + hermesHome: string, + mockUrl: string, + extraDisplayConfig?: string, + extraConfig?: string, + modelContextLength?: number, +): void { const configPath = path.join(hermesHome, 'config.yaml') - const displaySection = extraConfig - ? `\ndisplay:\n${extraConfig}\n` + const displaySection = extraDisplayConfig + ? `\ndisplay:\n${extraDisplayConfig}\n` : '' const config = `# Auto-generated by E2E test fixtures model: default: mock-model provider: mock -providers: +${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers: mock: api: ${mockUrl}/v1 name: Mock @@ -165,7 +172,7 @@ providers: models: mock-model: {} context_length: 4096 -${displaySection}` +${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}` fs.writeFileSync(configPath, config, 'utf8') } @@ -339,6 +346,10 @@ export interface MockBackendOptions { * `display.interim_assistant_messages`. */ extraDisplayConfig?: string + /** Additional top-level config.yaml sections for an E2E scenario. */ + extraConfig?: string + /** Override the mock model's context window for compression scenarios. */ + modelContextLength?: number } /** @@ -358,7 +369,13 @@ export async function setupMockBackend(options: MockBackendOptions = {}): Promis // 2. Create sandbox + write config const sandbox = createSandbox('mock') - writeMockProviderConfig(sandbox.hermesHome, mock.url, options.extraDisplayConfig) + writeMockProviderConfig( + sandbox.hermesHome, + mock.url, + options.extraDisplayConfig, + options.extraConfig, + options.modelContextLength, + ) writeEnvFile(sandbox.hermesHome) // 3. Build env + launch diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index e6bbaaa1fae9..93c0ce12a570 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -23,6 +23,8 @@ export const MOCK_REPLY = 'Hello from the mock inference server! The full boot c export interface MockServerOptions { /** Pause the matching stream after its first token for session-switch E2E coverage. */ holdFirstStreamForPrompt?: string + /** Pause the first completion whose request JSON contains this text. */ + holdFirstCompletionContaining?: string } export interface MockServer { @@ -30,7 +32,9 @@ export interface MockServer { url: string receivedPrompts: string[] waitForHeldStream: () => Promise + waitForHeldCompletion: () => Promise releaseHeldStream: () => void + heldCompletionCount: () => number close: () => Promise } @@ -248,6 +252,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise void) | null = null let releaseHeldStream: (() => void) | null = null + let heldCompletionCount = 0 const heldStreamStarted = new Promise(resolveHeld => { resolveHeldStreamStarted = resolveHeld }) @@ -313,6 +318,11 @@ export function startMockServer(options: MockServerOptions = {}): Promise { + streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion ? () => { + if (holdThisCompletion) { + heldCompletionCount++ + } resolveHeldStreamStarted?.() return heldStreamReleased } : undefined) } else { - nonStreamingTextResponse(res, model, MOCK_REPLY) + if (holdThisCompletion) { + heldCompletionCount++ + resolveHeldStreamStarted?.() + void heldStreamReleased.then(() => nonStreamingTextResponse(res, model, MOCK_REPLY)) + } else { + nonStreamingTextResponse(res, model, MOCK_REPLY) + } } }) @@ -443,7 +462,9 @@ export function startMockServer(options: MockServerOptions = {}): Promise heldStreamStarted, + waitForHeldCompletion: () => heldStreamStarted, releaseHeldStream: () => releaseHeldStream?.(), + heldCompletionCount: () => heldCompletionCount, close: () => new Promise((resolveClose, rejectClose) => { server.close((err) => { diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts index e4d9f60696e7..f15b8d0befcf 100644 --- a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -9,15 +9,23 @@ import { setupMockBackend, waitForAppReady, } from './fixtures' -import { receivedUserTexts, restartMockServer } from './mock-server' +import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server' -async function send(page: Page, text: string): Promise { +async function send(page: Page, text: string, delay = 15): Promise { const composer = page.locator('[contenteditable="true"]').first() await composer.click() - await composer.type(text, { delay: 15 }) + await composer.type(text, { delay }) await page.keyboard.press('Enter') } +async function pasteAndSend(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await page.keyboard.insertText(text) + await page.keyboard.press('Enter') +} + + async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise { await page.waitForFunction( expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false, @@ -81,3 +89,58 @@ test.describe('session compression', () => { await page.screenshot({ path: 'test-results/session-compression-continuation.png' }) }) }) + +test.describe('session compression in progress', () => { + let fixture: MockBackendFixture + + test.beforeAll(async () => { + fixture = await setupMockBackend({ + modelContextLength: 64_000, + extraConfig: `compression: + threshold_tokens: 22000 + protect_first_n: 0 + protect_last_n: 1 +auxiliary: + compression: + provider: custom + model: mock-model`, + mockServer: { + holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.', + } + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => { + const { page } = fixture + const queued = 'E2E_QUEUED_DURING_COMPACTION' + + // A normal message crosses the tiny configured context budget. The mock + // blocks only the resulting summary request, so these assertions run + // during automatic compaction rather than a slash-command path. + await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5)) + await waitForTranscript(page, MOCK_REPLY) + await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5)) + await waitForTranscript(page, MOCK_REPLY) + await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(500)) + await fixture.mock.waitForHeldCompletion() + await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible() + + const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') + await expect(primary).toHaveAttribute('aria-label', 'Queue message') + + await send(page, queued) + await expect(page.getByText('1 Queued')).toBeVisible() + expect(fixture.mock.heldCompletionCount()).toBe(1) + expect(receivedUserTexts()).not.toContain(queued) + await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') }) + + fixture.mock.releaseHeldStream() + await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1) + expect(fixture.mock.heldCompletionCount()).toBe(1) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx index c2a6bc6d7993..1ab89167eb6d 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -8,10 +8,11 @@ import { useComposerSubmit } from './use-composer-submit' interface SubmitHarnessOptions { attachments?: ComposerAttachment[] busy?: boolean + compacting?: boolean text?: string } -function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitHarnessOptions = {}) { +function renderSubmitHook({ attachments = [], busy = false, compacting = false, text = '' }: SubmitHarnessOptions = {}) { const draftRef = { current: text } const editor = document.createElement('div') editor.dataset.slot = 'composer-rich-input' @@ -33,6 +34,7 @@ function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitH activeQueueSessionKeyRef: { current: 'stored-session' }, attachments, busy, + compacting, clearDraft, disabled: false, draftRef, @@ -79,6 +81,23 @@ describe('useComposerSubmit busy-turn routing', () => { expect(onSubmit).not.toHaveBeenCalled() }) + it('queues a plain-text follow-up while the active turn is compacting', () => { + const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ + busy: true, + compacting: true, + text: 'wait for the summary' + }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(queueCurrentDraft).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + }) + it('runs slash commands immediately while busy', async () => { const { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true, diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index be053ddac82d..fb1cec2cf04a 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -17,6 +17,7 @@ interface UseComposerSubmitArgs { activeQueueSessionKeyRef: RefObject attachments: ComposerAttachment[] busy: boolean + compacting: boolean clearDraft: () => void disabled: boolean draftRef: RefObject @@ -51,6 +52,7 @@ export function useComposerSubmit({ activeQueueSessionKeyRef, attachments, busy, + compacting, clearDraft, disabled, draftRef, @@ -149,7 +151,7 @@ export function useComposerSubmit({ triggerHaptic('submit') clearDraft() dispatchSubmit(text) - } else if (!attachments.length && text.trim()) { + } else if (!compacting && !attachments.length && text.trim()) { // Cursor-style stop-and-correct: interrupt the live turn and redirect // it with this text. redirect() preserves the shown reasoning/work; if // the turn already ended, steerDraft re-queues so nothing is lost. diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d06f3d79c31d..e3f6454512f6 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -11,6 +11,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' +import { $compactionActive } from '@/store/compaction' import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history' import { POPOUT_WIDTH_REM } from '@/store/composer-popout' import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue' @@ -104,6 +105,7 @@ export function ChatBar({ // focus-bus key, and awaiting-input edge. Main scope = the legacy globals. const scope = useComposerScope() const attachments = useStore(scope.attachments.$attachments) + const compacting = useStore($compactionActive) const scrolledUp = useStore($threadScrolledUp) const autoSpeak = useStore($autoSpeakReplies) // The turn is parked on the user (clarify / approval / sudo / secret). Esc must @@ -230,11 +232,15 @@ export function ChatBar({ // Steer only makes sense mid-turn, text-only (the gateway can't carry images // into a tool result) and never for a slash command (those execute inline). - const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText + const canSteer = busy && !compacting && !!onSteer && attachments.length === 0 && isSteerableText // While busy: text redirects the live turn (Cursor-style stop-and-correct), // attachments queue for the next turn, an empty composer stops. - const busyAction: 'steer' | 'queue' | 'stop' = canSteer ? 'steer' : hasComposerPayload ? 'queue' : 'stop' + const busyAction: 'steer' | 'queue' | 'stop' = canSteer + ? 'steer' + : compacting || hasComposerPayload + ? 'queue' + : 'stop' // The submit engine — the orchestration seam where draft + queue meet. Owns // the submit decision tree, the send-with-restore primitive, and steer. @@ -243,6 +249,7 @@ export function ChatBar({ activeQueueSessionKeyRef, attachments, busy, + compacting, clearDraft, disabled, draftRef,