fix(web): make plan transitions admission-safe

This commit is contained in:
fz
2026-07-24 16:55:04 +08:00
parent aed744dbbe
commit e90b3bc5a2
18 changed files with 214 additions and 61 deletions
@@ -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())