fix(web): make plan transitions admission-safe
This commit is contained in:
@@ -147,27 +147,24 @@ describe('conversation slot inject surface', () => {
|
||||
const { instance, injected } = b.conversationSurface(ROOT)
|
||||
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
|
||||
instance.actions.setDraft(' ')
|
||||
injected.send(' ', 'queue')
|
||||
await injected.send(' ', 'queue')
|
||||
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
||||
expect(instance.store.getSnapshot().draft).toBe(' ')
|
||||
// Success: cleared and stays cleared.
|
||||
instance.actions.setDraft('hello')
|
||||
injected.send('hello', 'queue')
|
||||
await injected.send('hello', 'queue')
|
||||
expect(instance.store.getSnapshot().draft).toBe('')
|
||||
await Promise.resolve()
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
|
||||
// Failure: restored (draft still empty when the rejection lands).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
instance.actions.setDraft('retry me')
|
||||
injected.send('retry me', 'queue')
|
||||
await vi.waitFor(() => {
|
||||
expect(instance.store.getSnapshot().draft).toBe('retry me')
|
||||
})
|
||||
await expect(injected.send('retry me', 'queue')).rejects.toThrow(/agent-busy: b/)
|
||||
expect(instance.store.getSnapshot().draft).toBe('retry me')
|
||||
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
injected.send('retry me', 'queue')
|
||||
const failed = injected.send('retry me', 'queue').catch(() => {})
|
||||
instance.actions.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await failed
|
||||
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
|
||||
|
||||
@@ -12,7 +12,7 @@ afterEach(cleanup)
|
||||
|
||||
function setup(over?: Partial<InputBarProps>) {
|
||||
const props: InputBarProps = {
|
||||
draft: 'hello', running: false, disabled: false, error: null,
|
||||
draft: 'hello', running: false, submitting: false, disabled: false, error: null,
|
||||
variant: 'composer',
|
||||
onDraftChange: vi.fn(), onSend: vi.fn(), onStop: vi.fn(),
|
||||
...over,
|
||||
@@ -83,6 +83,15 @@ describe('running lock and primary button', () => {
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submission locks duplicate sends only until Host admission settles', () => {
|
||||
const { textarea, button, props } = setup({ submitting: true })
|
||||
expect(textarea.disabled).toBe(true)
|
||||
expect(textarea.placeholder).toBe('正在发送…')
|
||||
expect(button.disabled).toBe(true)
|
||||
fireEvent.click(button)
|
||||
expect(props.onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('idle primary sends and disables on empty draft', () => {
|
||||
const { button, props } = setup()
|
||||
fireEvent.click(button)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* share is a REAL createChatStore().create() instance (same construction path
|
||||
* as production), injected callbacks are spies.
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -106,14 +106,14 @@ describe('ConversationRoot', () => {
|
||||
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
|
||||
renderSlotChain?: ConversationRootProps['renderSlotChain'],
|
||||
) {
|
||||
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
|
||||
const { useSession, store: session } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init })
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'root', title: 'proj' },
|
||||
{ id: 's1', title: 'child', parentId: 'root' },
|
||||
])
|
||||
const chat = createChatStore().create()
|
||||
if (activeView !== undefined) chat.actions.setView(activeView)
|
||||
const send = vi.fn()
|
||||
const send = vi.fn(() => Promise.resolve())
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
// The renderSlot share as the outlet would bake it: renders a marker for
|
||||
@@ -141,7 +141,7 @@ describe('ConversationRoot', () => {
|
||||
stop={stop}
|
||||
open={open}
|
||||
/>)
|
||||
return { ui, chat, send, stop, open, renderSlot }
|
||||
return { ui, chat, session, send, stop, open, renderSlot }
|
||||
}
|
||||
|
||||
const tab = (id: string, label: string): ViewTab => ({ id, label })
|
||||
@@ -189,6 +189,37 @@ describe('ConversationRoot', () => {
|
||||
expect(send).toHaveBeenCalledWith('hi', 'queue')
|
||||
})
|
||||
|
||||
it('locks duplicate sends only while prompt admission is unresolved', async () => {
|
||||
const { session, send, stop } = bench([tab('chat', 'Chat')])
|
||||
let resolve!: () => void
|
||||
send.mockImplementationOnce(() => new Promise<void>((done) => { resolve = done }))
|
||||
const box = screen.getByPlaceholderText(/输入消息/)
|
||||
fireEvent.change(box, { target: { value: 'wait for mode' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
|
||||
expect((screen.getByRole('button', { name: '发送中' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((box as HTMLTextAreaElement).disabled).toBe(true)
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
|
||||
session.set({ ...session.getSnapshot(), running: true })
|
||||
const stopButton = await screen.findByRole('button', { name: '停止' }) as HTMLButtonElement
|
||||
expect(stopButton.disabled).toBe(false)
|
||||
fireEvent.click(stopButton)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolve()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy()
|
||||
})
|
||||
expect((box as HTMLTextAreaElement).disabled).toBe(true)
|
||||
session.set({ ...session.getSnapshot(), running: false })
|
||||
await waitFor(() => {
|
||||
expect((screen.getByRole('button', { name: '发送' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
expect((box as HTMLTextAreaElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => {
|
||||
const wait = new PendingWait('question', RpcId('rq'), sid('s1'),
|
||||
{ questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn())
|
||||
|
||||
Reference in New Issue
Block a user