feat(web): add plan mode controls

This commit is contained in:
fz
2026-07-24 12:55:23 +08:00
parent bc63b5fe00
commit 9a69feeec0
41 changed files with 1047 additions and 27 deletions

View File

@@ -0,0 +1,58 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PlanModeControl } from '../src/client/PlanModeControl.tsx'
import type { PlanModeControlInjected } from '../src/client/index.ts'
import { apply, inject } from '../src/client/index.ts'
const SID = 's-plan' as SessionId
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
slots.register({
name: 'root',
children: { 'conversation.composer.controls': { kind: 'list', scope: 'session' } },
} as never, () => null)
const setPlanMode = vi.fn(() => Promise.resolve({ ok: true, value: { active: false, pending: true } }))
ctx.provide('sessions', { manager: { get: () => ({ setPlanMode }) } })
ctx.provide('conversation', {})
return { ctx, slots, setPlanMode }
}
describe('ui-plan browser apply', () => {
it('declares every service it binds', () => {
expect(inject).toEqual(['slots', 'sessions', 'conversation'])
})
it('fails loud when conversation did not declare the controls slot', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', {})
ctx.provide('conversation', {})
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.composer.controls" is not declared/)
})
it('registers the control, bridges host results, and unregisters on teardown', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = b.slots.entries('conversation.composer.controls')[0]!
expect(entry.component).toBe(PlanModeControl)
expect(entry.options).toMatchObject({ id: 'plan-mode', order: 10 })
const injected = (entry.inject as unknown as (id: SessionId) => PlanModeControlInjected)(SID)
await expect(injected.setPlanMode(true)).resolves.toBeNull()
expect(b.setPlanMode).toHaveBeenCalledWith(true)
b.setPlanMode.mockResolvedValueOnce({
ok: false, error: { code: 'session-not-found', message: 'gone', details: {} },
} as never)
await expect(injected.setPlanMode(false)).resolves.toBe('gonesession-not-found')
await fiber.dispose()
expect(b.slots.entries('conversation.composer.controls')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,39 @@
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { EXIT_PLAN_MODE } from '@deepseek-ai/dsh-plan-mode'
import { WEB_PLAN_SECTION, apply, inject } from '../src/index.ts'
let ctx: Context | undefined
afterEach(async () => {
await ctx?.fiber.dispose()
ctx = undefined
})
describe('ui-plan node plugin', () => {
it('mounts the Web policy and stable exit tool for the selected feature lifecycle', async () => {
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
const feature = ctx.plugin({ inject: [...inject], apply })
await feature.await()
expect(ctx.get('planMode')).toBeDefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeDefined()
expect(WEB_PLAN_SECTION).toContain('Stay in plan mode until exit_plan_mode succeeds')
expect(WEB_PLAN_SECTION).toContain('Do not edit or write files')
expect(WEB_PLAN_SECTION).toContain('Make exit_plan_mode the only and final tool call')
expect((await ctx.systemPrompt.assemble()).sections)
.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'plan:policy', text: '' })]))
await feature.dispose()
expect(ctx.get('planMode')).toBeUndefined()
expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined()
expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name))
.not.toContain('plan:policy')
})
})

View File

@@ -0,0 +1,111 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type {
ConversationSnapshot, SessionId, SessionListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { PlanModeState } from '@deepseek-ai/dsh-client-connection/client'
import { PlanModeControl } from '../src/client/PlanModeControl.tsx'
afterEach(cleanup)
const SID = 's-plan' as SessionId
function setup(
planMode: PlanModeState | null,
setPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
running = false,
) {
const store = createSnapshotStore({ planMode, running })
const useSession = bindSnapshotSelector(store) as unknown as SnapshotSelectorHook<ConversationSnapshot>
const useSessions = (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>
const view = render(
<PlanModeControl
sessionId={SID}
useSession={useSession}
useSessions={useSessions}
setPlanMode={setPlanMode}
/>,
)
return { store, setPlanMode, view }
}
describe('PlanModeControl', () => {
it('hides an unavailable capability and renders committed modes', () => {
const unavailable = setup(null)
expect(unavailable.view.container.innerHTML).toBe('')
cleanup()
setup({ active: false })
expect(screen.getByTitle('当前为默认模式')).toBeTruthy()
expect((screen.getByRole('combobox', { name: '协作模式' }) as HTMLSelectElement).value).toBe('default')
})
it('treats pending field presence as the target, including pending false', () => {
setup({ active: false, pending: true })
expect(screen.getByText('计划 · 待生效')).toBeTruthy()
expect(screen.getByTitle(/当前为默认模式/)).toBeTruthy()
cleanup()
setup({ active: true, pending: false })
expect(screen.getByText('默认 · 待生效')).toBeTruthy()
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('default')
})
it('switches from the effective target and remains available while a turn runs', async () => {
let resolve!: (value: string | null) => void
const setPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: false }, setPlanMode, true)
const select = screen.getByRole('combobox', { name: '协作模式' }) as HTMLSelectElement
expect(select.disabled).toBe(false)
fireEvent.change(select, { target: { value: 'plan' } })
expect(setPlanMode).toHaveBeenCalledWith(true)
expect(select.disabled).toBe(true)
store.set({ planMode: { active: false, pending: true }, running: true })
resolve(null)
await waitFor(() => {
expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(false)
})
expect(screen.getByText('计划 · 待生效')).toBeTruthy()
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
expect(setPlanMode).toHaveBeenCalledTimes(1)
})
it('surfaces host and transport failures without changing the confirmed mode', async () => {
const setPlanMode = vi.fn()
.mockResolvedValueOnce('host said no')
.mockRejectedValueOnce(new Error('network down'))
.mockRejectedValueOnce('socket closed')
setup({ active: false }, setPlanMode)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
expect((await screen.findByText('模式切换失败')).getAttribute('title')).toBe('host said no')
expect(screen.getByTitle('当前为默认模式')).toBeTruthy()
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
expect(await screen.findByTitle('network down')).toBeTruthy()
expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(false)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
expect(await screen.findByTitle('socket closed')).toBeTruthy()
})
it('ignores in-flight fulfillment and rejection after unmount', () => {
let resolve!: (value: string | null) => void
const successful = setup(
{ active: false },
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
successful.view.unmount()
expect(() => { resolve(null) }).not.toThrow()
let reject!: (reason: unknown) => void
const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: false }, setPlanMode)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'plan' } })
view.unmount()
expect(() => { reject(new Error('late')) }).not.toThrow()
})
})