171 lines
5.3 KiB
TypeScript
171 lines
5.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { isSessionBusyError, markSubmitting, submitPrompt, type SubmitPromptDeps } from '../app/submissionCore.js'
|
|
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
|
|
import type { GatewayClient } from '../gatewayClient.js'
|
|
|
|
// A gateway double whose `input.detect_drop` resolution we control, so we can
|
|
// observe UI state DURING the async gap — the exact window the queue-mode race
|
|
// lived in.
|
|
function makeDeferredGateway() {
|
|
let resolveDrop: (v: unknown) => void = () => {}
|
|
|
|
const dropPromise = new Promise(res => {
|
|
resolveDrop = res
|
|
})
|
|
|
|
const calls: string[] = []
|
|
|
|
const gw = {
|
|
request: vi.fn((method: string) => {
|
|
calls.push(method)
|
|
|
|
if (method === 'input.detect_drop') {
|
|
return dropPromise
|
|
}
|
|
|
|
// prompt.submit et al: resolve immediately with a success shape.
|
|
return Promise.resolve({ status: 'streaming' })
|
|
})
|
|
} as unknown as GatewayClient
|
|
|
|
return { calls, gw, resolveDrop: (v: unknown = { matched: false }) => resolveDrop(v) }
|
|
}
|
|
|
|
function makeDeps(gw: GatewayClient, over: Partial<SubmitPromptDeps> = {}): SubmitPromptDeps {
|
|
return {
|
|
appendMessage: vi.fn(),
|
|
enqueue: vi.fn(),
|
|
expand: (t: string) => t,
|
|
gw,
|
|
setLastUserMsg: vi.fn(),
|
|
sys: vi.fn(),
|
|
...over
|
|
}
|
|
}
|
|
|
|
describe('submissionCore.submitPrompt — synchronous busy (queue-race fix)', () => {
|
|
beforeEach(() => {
|
|
resetUiState()
|
|
patchUiState({ sid: 'sess-1' })
|
|
})
|
|
|
|
it('flips busy=true SYNCHRONOUSLY, before input.detect_drop resolves', () => {
|
|
const { gw, resolveDrop } = makeDeferredGateway()
|
|
|
|
expect(getUiState().busy).toBe(false)
|
|
|
|
submitPrompt('hello', makeDeps(gw))
|
|
|
|
// The critical invariant: busy is already true even though the
|
|
// detect_drop RPC has NOT resolved yet. This is what makes a second,
|
|
// rapid submit take the local-enqueue branch instead of racing a second
|
|
// prompt.submit onto the backend.
|
|
expect(getUiState().busy).toBe(true)
|
|
expect(getUiState().status).toBe('running…')
|
|
|
|
resolveDrop()
|
|
})
|
|
|
|
it('regression: two back-to-back sends — the SECOND sees busy=true in the gap', async () => {
|
|
const { gw, resolveDrop } = makeDeferredGateway()
|
|
|
|
// Emulate dispatchSubmission's routing decision: it sends only when
|
|
// busy===false, otherwise it would enqueue. We assert the state the
|
|
// router reads, which is the real regression.
|
|
submitPrompt('first message', makeDeps(gw))
|
|
|
|
// Before the fix, busy was still false here (set only inside detect_drop's
|
|
// .then), so a second Enter would wrongly route into send() again.
|
|
const busyWhenSecondArrives = getUiState().busy
|
|
expect(busyWhenSecondArrives).toBe(true)
|
|
|
|
resolveDrop()
|
|
await Promise.resolve()
|
|
})
|
|
|
|
it('does not submit when there is no session, and does not mark busy', () => {
|
|
resetUiState() // sid: null
|
|
const { gw, calls } = makeDeferredGateway()
|
|
const sys = vi.fn()
|
|
|
|
submitPrompt('hello', makeDeps(gw, { sys }))
|
|
|
|
expect(getUiState().busy).toBe(false)
|
|
expect(sys).toHaveBeenCalledWith('session not ready yet')
|
|
expect(calls).not.toContain('input.detect_drop')
|
|
})
|
|
|
|
it('after detect_drop resolves (no file), it issues prompt.submit', async () => {
|
|
const { calls, gw, resolveDrop } = makeDeferredGateway()
|
|
|
|
submitPrompt('hi there', makeDeps(gw))
|
|
expect(calls).toEqual(['input.detect_drop'])
|
|
|
|
resolveDrop({ matched: false })
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(calls).toContain('prompt.submit')
|
|
})
|
|
})
|
|
|
|
describe('submissionCore.submitPrompt — literal submissions (startup -q queries)', () => {
|
|
beforeEach(() => {
|
|
resetUiState()
|
|
patchUiState({ sid: 'sess-1' })
|
|
})
|
|
|
|
it('skipDetectDrop submits directly without the detect_drop round-trip', async () => {
|
|
const { calls, gw } = makeDeferredGateway()
|
|
|
|
submitPrompt('!echo not-a-shell-escape', makeDeps(gw), true, undefined, { skipDetectDrop: true })
|
|
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(calls).not.toContain('input.detect_drop')
|
|
expect(calls).toContain('prompt.submit')
|
|
})
|
|
|
|
it('literal text reaches prompt.submit verbatim', async () => {
|
|
const submitted: string[] = []
|
|
|
|
const gw = {
|
|
request: vi.fn((method: string, params?: { text?: string }) => {
|
|
if (method === 'prompt.submit' && params?.text) {
|
|
submitted.push(params.text)
|
|
}
|
|
|
|
return Promise.resolve({ status: 'streaming' })
|
|
})
|
|
} as unknown as GatewayClient
|
|
|
|
submitPrompt('/model $(rm -rf ~)', makeDeps(gw), true, undefined, { skipDetectDrop: true })
|
|
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(submitted).toEqual(['/model $(rm -rf ~)'])
|
|
})
|
|
})
|
|
|
|
describe('submissionCore.markSubmitting', () => {
|
|
beforeEach(() => resetUiState())
|
|
|
|
it('sets busy + running status', () => {
|
|
markSubmitting()
|
|
expect(getUiState().busy).toBe(true)
|
|
expect(getUiState().status).toBe('running…')
|
|
})
|
|
})
|
|
|
|
describe('submissionCore.isSessionBusyError', () => {
|
|
it('matches the legacy busy rejections but not arbitrary errors', () => {
|
|
expect(isSessionBusyError(new Error('session busy'))).toBe(true)
|
|
expect(isSessionBusyError(new Error('waiting for model response'))).toBe(true)
|
|
expect(isSessionBusyError(new Error('some other failure'))).toBe(false)
|
|
expect(isSessionBusyError('not an error')).toBe(false)
|
|
})
|
|
})
|