fix(desktop): queue prompts during context compaction (#69783)

This commit is contained in:
ethernet 2026-07-22 23:19:22 -04:00 committed by GitHub
parent 5c4d358a7e
commit dbf4f69b72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 147 additions and 18 deletions

View file

@ -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

View file

@ -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<void>
waitForHeldCompletion: () => Promise<void>
releaseHeldStream: () => void
heldCompletionCount: () => number
close: () => Promise<void>
}
@ -248,6 +252,7 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
const receivedPrompts: string[] = []
let resolveHeldStreamStarted: (() => void) | null = null
let releaseHeldStream: (() => void) | null = null
let heldCompletionCount = 0
const heldStreamStarted = new Promise<void>(resolveHeld => {
resolveHeldStreamStarted = resolveHeld
})
@ -313,6 +318,11 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
const holdThisCompletion = Boolean(
options.holdFirstCompletionContaining &&
heldCompletionCount === 0 &&
JSON.stringify(parsed).includes(options.holdFirstCompletionContaining),
)
// Detect the interim-message test trigger: the user's message
// contains a specific keyword. The mock walks through the
@ -405,12 +415,21 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
)
streamTextResponse(res, model, MOCK_REPLY, holdThisStream ? () => {
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<MockSe
url,
receivedPrompts,
waitForHeldStream: () => heldStreamStarted,
waitForHeldCompletion: () => heldStreamStarted,
releaseHeldStream: () => releaseHeldStream?.(),
heldCompletionCount: () => heldCompletionCount,
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {

View file

@ -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<void> {
async function send(page: Page, text: string, delay = 15): Promise<void> {
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<void> {
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<void> {
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)
})
})

View file

@ -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,

View file

@ -17,6 +17,7 @@ interface UseComposerSubmitArgs {
activeQueueSessionKeyRef: RefObject<string | null>
attachments: ComposerAttachment[]
busy: boolean
compacting: boolean
clearDraft: () => void
disabled: boolean
draftRef: RefObject<string>
@ -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.

View file

@ -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,