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.
299 lines
15 KiB
TypeScript
299 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
// apply inject factories exercised end to end against the terminal thin
|
|
// shape: the conversation surface (views triple, send choreography incl.
|
|
// optimistic clear + failure restore THROUGH the declared store actions,
|
|
// openDetails = select action + layout orchestration, sessions.open
|
|
// navigation), the injectless-but-closeDetails details surface, and the
|
|
// one-callback empty surface. Complements chat-apply.spec.tsx (registration)
|
|
// and selection-survival.spec.ts (store axis). History opening is NOT an
|
|
// inject concern anymore — the runtime sessions service opens on watch
|
|
// (sessions-service.spec.ts owns that behavior).
|
|
|
|
import { Context } from 'cordis'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { cleanup } from '@testing-library/react'
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
|
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import type {
|
|
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import type { createChatStore } from '../src/client/stores.ts'
|
|
|
|
afterEach(cleanup)
|
|
|
|
const ROOT = 'root-1' as SessionId
|
|
|
|
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
|
|
type ChatActions = ChatInstance['actions']
|
|
|
|
const SCOPE_TAG: symbol = (() => {
|
|
const recorded: (string | symbol)[] = []
|
|
const spy = new Proxy(new Context(), {
|
|
get(target, prop, receiver) {
|
|
recorded.push(prop)
|
|
return Reflect.get(target, prop, receiver)
|
|
},
|
|
})
|
|
void scopeOf(spy as Context)
|
|
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
|
|
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
|
|
return symbol
|
|
})()
|
|
|
|
async function bench() {
|
|
const ctx = new Context()
|
|
const slotsFiber = ctx.plugin(SlotsService)
|
|
await slotsFiber.await()
|
|
const slots = ctx.get('slots') as SlotsService
|
|
|
|
const listStore = createSnapshotStore<SessionListState>({
|
|
ids: [ROOT],
|
|
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
|
current: ROOT,
|
|
} as SessionListState)
|
|
const sessionFake = {
|
|
open: vi.fn(() => Promise.resolve()),
|
|
loadOlder: vi.fn(() => Promise.resolve()),
|
|
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
|
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
|
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
|
|
() => Promise.resolve({ ok: true, value: { accepted: true } })),
|
|
permissions: vi.fn<() => Promise<{ ok: boolean; value?: { options: { value: string; name: string }[]; currentValue: string }; error?: { code: string; message: string } }>>(
|
|
() => Promise.resolve({ ok: true, value: { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } })),
|
|
setPermission: vi.fn<() => Promise<{ ok: boolean; value?: { currentValue: string }; error?: { code: string; message: string } }>>(
|
|
() => Promise.resolve({ ok: true, value: { currentValue: 'danger-full-access' } })),
|
|
}
|
|
const scopes = new Map<SessionId, Context>()
|
|
const mint = (id: SessionId): Context => {
|
|
let scoped = scopes.get(id)
|
|
if (scoped === undefined) {
|
|
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id }) as Context
|
|
scopes.set(id, scoped)
|
|
}
|
|
return scoped
|
|
}
|
|
const sessionsFake = {
|
|
list: listStore,
|
|
manager: { get: () => sessionFake },
|
|
scope: (id: SessionId) => mint(id),
|
|
cell: () => undefined,
|
|
scopeOf,
|
|
create: vi.fn(() => Promise.resolve(ROOT)),
|
|
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
|
|
open: vi.fn(),
|
|
}
|
|
ctx.provide('sessions', sessionsFake)
|
|
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
|
ctx.provide('layout', layoutFake)
|
|
ctx.provide('i18n', { bind: () => (key: string) => key })
|
|
|
|
// The AppFrame role: the three conversation-package slots must be declared
|
|
// by a live entry before apply can contribute into them (the stand-in
|
|
// consumes renderSlot to satisfy the declare-means-render check).
|
|
slots.register({
|
|
name: 'root',
|
|
children: {
|
|
'conversation': { kind: 'single', scope: 'session' },
|
|
'details': { kind: 'single', scope: 'session' },
|
|
'conversation.empty': { kind: 'single', scope: 'root' },
|
|
},
|
|
}, (_p: { renderSlot?: unknown }) => null)
|
|
|
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
|
await fiber.await()
|
|
|
|
// Reach the render-side entry view (inject + store handle) the way the
|
|
// renderer does: through the host face.
|
|
let host: SlotRendererHost | undefined
|
|
slots.install({ renderRoot: (h) => { host = h; return null } })
|
|
slots.renderSlot('root', {})
|
|
const hostFace = host!
|
|
const entryOf = (key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
|
|
/** Resolve store instance + call the inject the way the outlet would. */
|
|
const conversationSurface = (id: SessionId) => {
|
|
const entry = entryOf('conversation')
|
|
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
|
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected)(
|
|
id, instance.actions)
|
|
return { instance, injected }
|
|
}
|
|
/** Same resolution for the chat entry riding the view ring. */
|
|
const chatViewSurface = (id: SessionId) => {
|
|
const entry = entryOf('conversation.view')
|
|
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
|
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
|
|
id, instance.actions)
|
|
return { instance, injected }
|
|
}
|
|
return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
|
|
}
|
|
|
|
describe('conversation slot inject surface', () => {
|
|
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
|
|
const b = await bench()
|
|
const { injected } = b.conversationSurface(ROOT)
|
|
// Assembly has no session side effects: opening the event window belongs
|
|
// to the runtime watch path, not the inject factory.
|
|
expect(b.sessionFake.open).not.toHaveBeenCalled()
|
|
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
|
|
injected.open(ROOT)
|
|
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
|
// loadOlder moved to the chat view entry's face (the ring rider).
|
|
const chatView = b.chatViewSurface(ROOT)
|
|
chatView.injected.loadOlder()
|
|
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('permissions/setPermission thread the object layer; empty options and failures fold to null', async () => {
|
|
const b = await bench()
|
|
const { injected } = b.conversationSurface(ROOT)
|
|
expect(await injected.permissions()).toMatchObject({ currentValue: 'workspace-write' })
|
|
expect(await injected.setPermission('danger-full-access')).toBe('danger-full-access')
|
|
// Empty options (permission-less host) and the error branch both hide the control.
|
|
b.sessionFake.permissions.mockResolvedValueOnce({ ok: true, value: { options: [], currentValue: 'custom' } })
|
|
expect(await injected.permissions()).toBeNull()
|
|
b.sessionFake.permissions.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
|
|
expect(await injected.permissions()).toBeNull()
|
|
b.sessionFake.setPermission.mockResolvedValueOnce({ ok: false, error: { code: 'bad-request', message: 'x' } })
|
|
expect(await injected.setPermission('nope')).toBeNull()
|
|
})
|
|
|
|
it('registers the approval takeover on the composer chain: routing selector, no inject face', async () => {
|
|
const b = await bench()
|
|
const entry = b.slots.entries('conversation.composer')[0]!
|
|
expect(entry.inject).toBeUndefined()
|
|
// The selector narrows the chain currency: approval wait in → that wait; none → null.
|
|
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
|
|
const approval = { kind: 'approval' }
|
|
expect(select({ interactions: [{ kind: 'question' }, approval] })).toBe(approval)
|
|
expect(select({ interactions: [{ kind: 'question' }] })).toBeNull()
|
|
expect(select({ interactions: [] })).toBeNull()
|
|
})
|
|
|
|
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
|
|
const b = await bench()
|
|
const { instance, injected } = b.conversationSurface(ROOT)
|
|
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
|
|
instance.actions.setDraft(' ')
|
|
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')
|
|
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')
|
|
})
|
|
// 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')
|
|
instance.actions.setDraft('typed during flight')
|
|
await new Promise(r => setTimeout(r, 0))
|
|
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' } })
|
|
injected.stop()
|
|
await new Promise(r => setTimeout(r, 0))
|
|
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
|
|
const b = await bench()
|
|
const entry = b.entryOf('conversation')
|
|
const instance = b.hostFace.storeOf(entry, ROOT) as ChatInstance
|
|
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected
|
|
// Unknown session: sessions.scope answers nothing.
|
|
;(b.sessionsFake.scope as unknown) = () => undefined
|
|
expect(() => injectFn(ROOT, instance.actions)).toThrow(/resolved no scope/)
|
|
// A scope minted outside the service tree: no conversation service on it.
|
|
const foreign = new Context()
|
|
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
|
|
expect(() => injectFn(ROOT, instance.actions)).toThrow(/unavailable through the session scope/)
|
|
})
|
|
|
|
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
|
const b = await bench()
|
|
const { instance, injected } = b.chatViewSurface(ROOT)
|
|
injected.openDetails({ turnSeq: 2, callId: 'c1' })
|
|
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
|
|
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
|
|
// The chat view shares the conversation entry's store instance: selection
|
|
// writes land where the skeleton and details read.
|
|
const conv = b.conversationSurface(ROOT)
|
|
expect(conv.instance).toBe(instance)
|
|
})
|
|
|
|
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
|
const b = await bench()
|
|
const { injected } = b.conversationSurface(ROOT)
|
|
const before = injected.views.version()
|
|
const listener = vi.fn()
|
|
const unsub = injected.views.subscribe(listener)
|
|
// A second ring rider (what ui-trajectory does in production).
|
|
const off = b.slots.register(
|
|
{ name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never)
|
|
await Promise.resolve() // ledger notifications batch per microtask
|
|
expect(listener).toHaveBeenCalled()
|
|
expect(injected.views.version()).toBeGreaterThan(before)
|
|
expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
|
|
// Label falls back to the id when a rider declares none.
|
|
const off2 = b.slots.register(
|
|
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
|
|
expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
|
|
off()
|
|
off2()
|
|
unsub()
|
|
})
|
|
})
|
|
|
|
describe('details and empty inject surfaces', () => {
|
|
it('details injects the one layout callback; selection rides the shared store instead', async () => {
|
|
const b = await bench()
|
|
const entry = b.entryOf('details')
|
|
const injected = (entry.inject as unknown as () => DetailsInjected)()
|
|
expect(Object.keys(injected)).toEqual(['closeDetails'])
|
|
injected.closeDetails()
|
|
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
|
|
// The shared handle: details resolves the SAME instance conversation writes.
|
|
const conv = b.hostFace.storeOf(b.entryOf('conversation'), ROOT)
|
|
const details = b.hostFace.storeOf(entry, ROOT)
|
|
expect(details).toBe(conv)
|
|
})
|
|
|
|
it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => {
|
|
const b = await bench()
|
|
const entry = b.entryOf('conversation.empty')
|
|
expect(entry.store).toBeUndefined()
|
|
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
|
expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession'])
|
|
await injected.startSession({ text: 'go', mode: 'queue' })
|
|
expect(b.sessionsFake.create).toHaveBeenCalled()
|
|
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
|
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
|
|
b.sessionsFake.open.mockClear()
|
|
await injected.createWorkspaceSession('Fresh')
|
|
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
|
|
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
|
})
|
|
|
|
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
|
|
const b = await bench()
|
|
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
|
|
// Tear the service's own fiber (registry keyed by the class): the slot
|
|
// entries survive, so the gesture-time read hits the loud branch.
|
|
b.ctx.registry.delete(ConversationService)
|
|
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
|
|
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
|
|
})
|
|
})
|