mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
test(desktop): cover post-compression queueing
Exercise automatic context compression, a subsequent held normal turn, and an Enter-submitted follow-up. Assert the follow-up remains queued until the normal turn completes, then dispatches once.
This commit is contained in:
parent
0842fdbb11
commit
c0c0b4e6d9
2 changed files with 101 additions and 13 deletions
|
|
@ -23,8 +23,10 @@ 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. */
|
||||
/** Pause the first completion whose request JSON contains this text. */
|
||||
holdFirstCompletionContaining?: string
|
||||
/** Pause the first completion after another hold whose request JSON contains this text. */
|
||||
holdFirstCompletionContainingAfterHeldCompletion?: string
|
||||
/** Absolute sandbox path written by the verify-on-stop scripted tool call. */
|
||||
verificationWritePath?: string
|
||||
}
|
||||
|
|
@ -285,12 +287,20 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
|||
let resolveHeldStreamStarted: (() => void) | null = null
|
||||
let releaseHeldStream: (() => void) | null = null
|
||||
let heldCompletionCount = 0
|
||||
const heldStreamStarted = new Promise<void>(resolveHeld => {
|
||||
resolveHeldStreamStarted = resolveHeld
|
||||
})
|
||||
const heldStreamReleased = new Promise<void>(resolveRelease => {
|
||||
releaseHeldStream = resolveRelease
|
||||
})
|
||||
let heldCompletionAfterHeldCompletion = false
|
||||
let heldStreamStarted: Promise<void>
|
||||
let heldStreamReleased: Promise<void>
|
||||
|
||||
const resetHeldStreamGate = (): void => {
|
||||
heldStreamStarted = new Promise<void>(resolveHeld => {
|
||||
resolveHeldStreamStarted = resolveHeld
|
||||
})
|
||||
heldStreamReleased = new Promise<void>(resolveRelease => {
|
||||
releaseHeldStream = resolveRelease
|
||||
})
|
||||
}
|
||||
|
||||
resetHeldStreamGate()
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers — the Electron renderer doesn't need them, but they
|
||||
// don't hurt and make the server usable from a browser context too.
|
||||
|
|
@ -355,6 +365,15 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
|||
heldCompletionCount === 0 &&
|
||||
JSON.stringify(parsed).includes(options.holdFirstCompletionContaining),
|
||||
)
|
||||
const holdThisCompletionAfterHeldCompletion = Boolean(
|
||||
options.holdFirstCompletionContainingAfterHeldCompletion &&
|
||||
heldCompletionCount === 1 &&
|
||||
!heldCompletionAfterHeldCompletion &&
|
||||
JSON.stringify(parsed).includes(options.holdFirstCompletionContainingAfterHeldCompletion),
|
||||
)
|
||||
if (holdThisCompletionAfterHeldCompletion) {
|
||||
heldCompletionAfterHeldCompletion = true
|
||||
}
|
||||
|
||||
// Detect the interim-message test trigger: the user's message
|
||||
// contains a specific keyword. The mock walks through the
|
||||
|
|
@ -459,10 +478,11 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
|||
|
||||
if (stream) {
|
||||
const holdThisStream = Boolean(
|
||||
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
|
||||
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
|
||||
(typeof lastUserMessage?.content === 'string' && (
|
||||
options.holdFirstStreamForPrompt && lastUserMessage.content.includes(options.holdFirstStreamForPrompt)
|
||||
)),
|
||||
)
|
||||
streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion ? () => {
|
||||
streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion || holdThisCompletionAfterHeldCompletion ? () => {
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
}
|
||||
|
|
@ -470,8 +490,10 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
|||
return heldStreamReleased
|
||||
} : undefined)
|
||||
} else {
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
if (holdThisCompletion || holdThisCompletionAfterHeldCompletion) {
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
}
|
||||
resolveHeldStreamStarted?.()
|
||||
void heldStreamReleased.then(() => nonStreamingTextResponse(res, model, MOCK_REPLY))
|
||||
} else {
|
||||
|
|
@ -510,7 +532,10 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
|||
receivedPrompts,
|
||||
waitForHeldStream: () => heldStreamStarted,
|
||||
waitForHeldCompletion: () => heldStreamStarted,
|
||||
releaseHeldStream: () => releaseHeldStream?.(),
|
||||
releaseHeldStream: () => {
|
||||
releaseHeldStream?.()
|
||||
resetHeldStreamGate()
|
||||
},
|
||||
heldCompletionCount: () => heldCompletionCount,
|
||||
close: () =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
|
|
|
|||
|
|
@ -144,3 +144,66 @@ auxiliary:
|
|||
expect(fixture.mock.heldCompletionCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('session compression handoff queueing', () => {
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeEach(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.',
|
||||
holdFirstCompletionContainingAfterHeldCompletion: 'E2E_POST_COMPACTION_RUNNING',
|
||||
},
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('queues an Enter-submitted prompt while the post-compression turn is still running', async ({}, testInfo) => {
|
||||
const { mock, page } = fixture
|
||||
const compressionTrigger = 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(500)
|
||||
const running = 'E2E_POST_COMPACTION_RUNNING'
|
||||
const queued = 'E2E_QUEUED_AFTER_COMPACTION'
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
|
||||
// Cross the threshold, complete compaction, then start a separate normal
|
||||
// turn. This mirrors the user-visible sequence rather than holding the
|
||||
// turn that caused the compaction itself.
|
||||
await pasteAndSend(page, 'E2E_POST_COMPACTION_HISTORY_ONE '.repeat(5))
|
||||
await waitForTranscript(page, MOCK_REPLY)
|
||||
await pasteAndSend(page, 'E2E_POST_COMPACTION_HISTORY_TWO '.repeat(5))
|
||||
await waitForTranscript(page, MOCK_REPLY)
|
||||
await pasteAndSend(page, compressionTrigger)
|
||||
await mock.waitForHeldCompletion()
|
||||
expect(mock.heldCompletionCount()).toBe(1)
|
||||
await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
|
||||
|
||||
mock.releaseHeldStream()
|
||||
await expect(page.getByText(MOCK_REPLY, { exact: true })).toHaveCount(4)
|
||||
|
||||
await pasteAndSend(page, running)
|
||||
await mock.waitForHeldStream()
|
||||
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Queue message')
|
||||
await pasteAndSend(page, queued)
|
||||
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
expect(mock.receivedPrompts).not.toContain(queued)
|
||||
await page.screenshot({ path: testInfo.outputPath('queued-after-compression-handoff.png') })
|
||||
|
||||
mock.releaseHeldStream()
|
||||
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === queued), { timeout: 30_000 }).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue