The web host now composes the sandboxed product path (sandbox-local + sandbox-policy behind bash-sandbox/fs-sandbox, with user-approval and permission on top); BootHostOptions.sandbox carries the deployment defaults (workspace-write + ask). createApiProxy owns the approval pending registry: a ctx.approval ask becomes an answerable approval/requested mux frame with a stable rpcId, replayed verbatim on every mux open until settled; respond routes by the echoed rpcId, validates the ApprovalResponsePayload audit correlation, and broadcasts approval/resolved; the ask's abort signal withdraws the question as cancelled. session.permissions / session.setPermission project ctx.permission into a protocol-owned PermissionOption select; idle switches are held last-write-wins and flushed into the next prompted turn (the ACP bridge's anchoring pattern). The shared hasOpenTurn fold moved to dsh-session, deduplicating the private copies in user-approval, the ACP bridge, and the proxy. Client, per the designer draft: a pending approval takes over the composer (ApprovalPanel replaces the InputBar — amber strip, justification headline, paired command, one-shot refuse/allow, keyed by rpcId so a queued second approval remounts live; the resolved frame restores the composer); the sidebar session row shows an amber waiting-approval dot that outranks the running ring (manager-tracked approvalId set, idempotent under mux-open replays, cleared per connection generation, lit for uninstantiated sessions too); the permission selector is a composer bottom-row chip over an invisible native select, with a presentation-only title-case transform (workspace-write renders as Workspace Write; wire names untouched). Question placeholders stay in the message flow. The connection fixture mirrors the host behavior for keyless browser acceptance.
347 lines
16 KiB
TypeScript
347 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* Skeleton acceptance over the four-share props form: empty-state transition
|
|
* (same InputBar component in hero position, startSession submit, in-component
|
|
* cwd derivation), ConversationRoot view switching through the store's view
|
|
* field, DetailsPanel selection through the shared store. Components stay
|
|
* pure — the framework shares are stubbed (useSession/useSessions), the store
|
|
* 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 { 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'
|
|
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
|
import type { ConversationSnapshot, PendingInteraction, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
|
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
|
|
// Export discipline: packages/client/AGENTS.md.
|
|
import { createChatStore } from '../src/client/stores.ts'
|
|
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
|
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
|
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
|
|
|
|
const sid = (s: string): SessionId => s as SessionId
|
|
|
|
afterEach(cleanup)
|
|
beforeEach(() => {
|
|
// jsdom normally provides localStorage; some host Node builds surface it as undefined.
|
|
globalThis.localStorage?.clear()
|
|
})
|
|
|
|
/** Minimal conversation snapshot slice the skeleton reads. */
|
|
interface FakeSnapshot {
|
|
nodes: readonly {
|
|
kind: string
|
|
seq?: number
|
|
time?: number
|
|
callId?: string
|
|
call?: { name: string; argsRaw: string } | null
|
|
callTime?: number | null
|
|
content?: readonly { type: string; text?: string }[]
|
|
isError?: boolean
|
|
callView?: null
|
|
resultView?: null
|
|
}[]
|
|
runningCalls: readonly {
|
|
callId: string
|
|
name: string
|
|
argsRaw: string
|
|
turn?: number
|
|
step?: number
|
|
time?: number
|
|
callView?: null
|
|
}[]
|
|
running: boolean
|
|
removed: boolean
|
|
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
|
pending: readonly PendingInteraction[]
|
|
}
|
|
|
|
function fakeSession(init: Partial<FakeSnapshot> = {}) {
|
|
const store = createSnapshotStore<FakeSnapshot>({
|
|
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init,
|
|
})
|
|
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
|
}
|
|
|
|
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
|
|
function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
|
|
const store = createSnapshotStore<SessionListState>({
|
|
ids: rows.map(r => sid(r.id)),
|
|
byId: Object.fromEntries(rows.map(r => [r.id, {
|
|
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
|
|
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
|
|
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
|
|
}])),
|
|
current: undefined,
|
|
} as SessionListState)
|
|
return { store, useSessions: bindSnapshotSelector(store) }
|
|
}
|
|
|
|
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
|
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
|
|
|
|
describe('EmptyState', () => {
|
|
const noopCreate = () => Promise.resolve()
|
|
|
|
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
|
|
const { useSessions } = fakeSessions([
|
|
{ id: 'a', title: 'a', cwd: '/w/app' },
|
|
{ id: 'b', title: 'b', cwd: '/w/lib' },
|
|
{ id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes
|
|
])
|
|
let reject!: (e: Error) => void
|
|
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
|
render(
|
|
<EmptyState
|
|
useSessions={useSessions}
|
|
startSession={startSession}
|
|
createWorkspaceSession={noopCreate}
|
|
/>,
|
|
)
|
|
|
|
const trigger = screen.getByRole('button', { name: '项目目录' })
|
|
fireEvent.click(trigger)
|
|
const menu = screen.getByRole('menu')
|
|
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
|
.toEqual(['app', 'lib', 'New Workspace'])
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
|
|
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
|
|
fireEvent.change(box, { target: { value: '造一个轮子' } })
|
|
fireEvent.keyDown(box, { key: 'Enter' })
|
|
expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
|
|
|
|
reject(new Error('后端拒收'))
|
|
expect(await screen.findByText(/后端拒收/)).toBeTruthy()
|
|
// Draft survives the failure for retry.
|
|
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
|
|
})
|
|
|
|
it('Use a existing folder opens the path modal and Open Folder sets the chip', () => {
|
|
const { useSessions } = fakeSessions([])
|
|
render(
|
|
<EmptyState
|
|
useSessions={useSessions}
|
|
startSession={() => Promise.resolve()}
|
|
createWorkspaceSession={noopCreate}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
|
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
|
|
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
|
|
expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy()
|
|
const path = screen.getByLabelText('Folder path') as HTMLInputElement
|
|
fireEvent.change(path, { target: { value: '/tmp/fresh' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Open Folder' }))
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh')
|
|
})
|
|
|
|
it('Create new opens the modal and createWorkspaceSession succeeds', async () => {
|
|
const { useSessions } = fakeSessions([])
|
|
const createWorkspaceSession = vi.fn(() => Promise.resolve())
|
|
render(
|
|
<EmptyState
|
|
useSessions={useSessions}
|
|
startSession={() => Promise.resolve()}
|
|
createWorkspaceSession={createWorkspaceSession}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
|
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
|
|
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy()
|
|
const name = screen.getByLabelText('Workspace name') as HTMLInputElement
|
|
expect(name.value).toBe('New WorkSpace')
|
|
fireEvent.change(name, { target: { value: 'My Proj' } })
|
|
fireEvent.keyDown(name, { key: 'Enter' })
|
|
await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj'))
|
|
})
|
|
|
|
it('Create modal Cancel dismisses without calling createWorkspaceSession', () => {
|
|
const { useSessions } = fakeSessions([])
|
|
const createWorkspaceSession = vi.fn(() => Promise.resolve())
|
|
render(
|
|
<EmptyState
|
|
useSessions={useSessions}
|
|
startSession={() => Promise.resolve()}
|
|
createWorkspaceSession={createWorkspaceSession}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
|
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
expect(createWorkspaceSession).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('ConversationRoot', () => {
|
|
function bench(
|
|
tabs: ViewTab[], activeView?: string, init: Partial<FakeSnapshot> = {},
|
|
renderSlotChain?: ConversationRootProps['renderSlotChain'],
|
|
) {
|
|
const { useSession } = 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 stop = vi.fn()
|
|
const open = vi.fn()
|
|
// The renderSlot share as the outlet would bake it: renders a marker for
|
|
// the ring key carrying the active-id filter (a Mock cannot satisfy the
|
|
// generic method type directly — cast once at the prop seam).
|
|
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
|
|
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
|
|
))
|
|
const ui = render(
|
|
<ConversationRoot
|
|
sessionId={sid('s1')}
|
|
useSession={useSession}
|
|
useSessions={useSessions}
|
|
useStore={bindSnapshotSelector(chat)}
|
|
actions={chat.actions}
|
|
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
|
|
renderSlotChain={renderSlotChain ?? ((_key, _owner, opts) => opts?.fallback ?? null)}
|
|
SessionProvider={SessionProviderStub}
|
|
views={{
|
|
list: () => tabs,
|
|
subscribe: () => () => {},
|
|
version: () => 1,
|
|
}}
|
|
send={send}
|
|
stop={stop}
|
|
open={open}
|
|
permissions={() => Promise.resolve(null)}
|
|
setPermission={() => Promise.resolve(null)}
|
|
/>)
|
|
return { ui, chat, send, stop, open, renderSlot }
|
|
}
|
|
|
|
const tab = (id: string, label: string): ViewTab => ({ id, label })
|
|
|
|
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
|
|
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
|
expect(screen.getByText('proj')).toBeTruthy()
|
|
expect(screen.getByText('child')).toBeTruthy()
|
|
expect(screen.getByText(/2 turns/)).toBeTruthy()
|
|
expect(screen.getByTestId('view-chat')).toBeTruthy()
|
|
// Ancestor crumb navigates; current crumb is disabled.
|
|
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
|
|
expect(open).toHaveBeenCalledWith('root')
|
|
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
|
|
it('switches views through the store view field and falls back on unknown ids', () => {
|
|
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
|
expect(chat.store.getSnapshot().view).toBe('trajectory')
|
|
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
|
|
cleanup()
|
|
// A stale persisted id (its view plugin unloaded) falls to the first view.
|
|
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
|
|
expect(screen.getByTestId('view-chat')).toBeTruthy()
|
|
})
|
|
|
|
it('renders the active view through the declared ring slot with the only filter', () => {
|
|
const { renderSlot } = bench([tab('chat', 'Chat')])
|
|
// No owner share: views take everything from the standard kit (contract).
|
|
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
|
|
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
|
|
})
|
|
|
|
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
|
|
const { chat, send } = bench([tab('chat', 'Chat')])
|
|
expect(screen.queryByRole('tablist')).toBeNull()
|
|
const box = screen.getByPlaceholderText(/输入消息/)
|
|
fireEvent.change(box, { target: { value: 'hi' } })
|
|
// Typing goes through actions.setDraft into the shared store.
|
|
expect(chat.store.getSnapshot().draft).toBe('hi')
|
|
fireEvent.keyDown(box, { key: 'Enter' })
|
|
expect(send).toHaveBeenCalledWith('hi', 'queue')
|
|
})
|
|
|
|
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())
|
|
// A matching entry takes the composer over.
|
|
const renderSlotChain = vi.fn(() => <div>question takeover</div>) as unknown as ConversationRootProps['renderSlotChain']
|
|
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
|
|
expect(screen.getByText('question takeover')).toBeTruthy()
|
|
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
|
|
// The owner dispatches the raw pending list (chain currency); routing
|
|
// lives in entry selectors, not here.
|
|
expect(renderSlotChain).toHaveBeenCalledWith(
|
|
'conversation.composer',
|
|
expect.objectContaining({
|
|
interactions: expect.arrayContaining([expect.objectContaining({ key: 'q:rq' })]),
|
|
}),
|
|
expect.objectContaining({ fallback: expect.anything() }),
|
|
)
|
|
cleanup()
|
|
// Zero registered entries (default all-decline stub): the fallback IS the
|
|
// default InputBar — behavior equals the pre-chain composer.
|
|
bench([tab('chat', 'Chat')], undefined, { pending: [wait] })
|
|
expect(screen.getByPlaceholderText(/输入消息/)).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('DetailsPanel', () => {
|
|
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
|
|
const { useSession } = fakeSession(snapshot)
|
|
const { useSessions } = fakeSessions([])
|
|
const chat = createChatStore().create()
|
|
if (selection !== null) chat.actions.select(selection)
|
|
const closeDetails = vi.fn()
|
|
render(
|
|
<DetailsPanel
|
|
sessionId={sid('s1')}
|
|
useSession={useSession}
|
|
useSessions={useSessions}
|
|
useStore={bindSnapshotSelector(chat)}
|
|
actions={chat.actions}
|
|
closeDetails={closeDetails}
|
|
/>)
|
|
return { closeDetails, chat }
|
|
}
|
|
|
|
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
|
|
const { closeDetails } = benchDetails({
|
|
nodes: [{
|
|
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
|
call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
|
|
callTime: 500,
|
|
content: [{ type: 'text', text: 'file-a\nfile-b' }],
|
|
isError: false, callView: null, resultView: null,
|
|
}],
|
|
}, { turnSeq: 1, callId: 'c1' })
|
|
expect(screen.getByText('bash')).toBeTruthy()
|
|
expect(screen.getByText(/"cmd": "ls"/)).toBeTruthy()
|
|
expect(screen.getByText(/file-a/)).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: '关闭详情' }))
|
|
expect(closeDetails).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('shows the empty hint without a selection and the running state for open calls', () => {
|
|
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null)
|
|
expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
|
|
cleanup()
|
|
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' })
|
|
expect(screen.getByText('运行中…')).toBeTruthy()
|
|
})
|
|
|
|
it('reports an out-of-window call distinctly', () => {
|
|
benchDetails({}, { turnSeq: 1, callId: 'ghost' })
|
|
expect(screen.getByText(/不在当前窗口内/)).toBeTruthy()
|
|
})
|
|
})
|