feat: slash system / input service / agent scope
This commit is contained in:
@@ -20,7 +20,7 @@ import type {
|
||||
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
@@ -53,20 +53,21 @@ async function bench() {
|
||||
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT],
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } },
|
||||
current: ROOT,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const sessionFake = {
|
||||
sessionId: ROOT,
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
loadOlder: vi.fn(() => Promise.resolve()),
|
||||
updatePendingPrompt: vi.fn(),
|
||||
retryPendingPrompt: vi.fn(),
|
||||
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 } })),
|
||||
// Observable face (the input machine's queue read face rides it).
|
||||
getSnapshot: () => ({ queue: [] }),
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
const mint = (id: SessionId): Context => {
|
||||
@@ -77,24 +78,30 @@ async function bench() {
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
type TestProvider = {
|
||||
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
|
||||
hooks?: Record<string, unknown>; props?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
const providers: TestProvider[] = []
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
|
||||
scope: (id: SessionId) => mint(id),
|
||||
cell: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
|
||||
scopeOf,
|
||||
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
const workspaceStore = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const workspacesFake = {
|
||||
list: workspaceStore,
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
connectWorkspace: vi.fn(async () => ROOT),
|
||||
}
|
||||
ctx.provide('workspaces', workspacesFake)
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
@@ -107,9 +114,8 @@ async function bench() {
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
@@ -122,15 +128,23 @@ async function bench() {
|
||||
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]!
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]!
|
||||
/** Resolve store instance + call the inject the way the outlet would. */
|
||||
const conversationSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation')
|
||||
const entry = entryOf('conversation.session')
|
||||
const instance = hostFace.storeOf(entry, id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected)(
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const residentSurface = (id: SessionId | undefined) => {
|
||||
const entry = entryOf('conversation')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
|
||||
}
|
||||
const composerSurface = (id: SessionId | undefined) => {
|
||||
const entry = entryOf('conversation.composer.bar')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
|
||||
}
|
||||
/** Same resolution for the chat entry riding the view ring. */
|
||||
const chatViewSurface = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.view')
|
||||
@@ -139,12 +153,19 @@ async function bench() {
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const emptySurface = () => {
|
||||
const entry = entryOf('conversation.empty')
|
||||
return (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
/** Materialize the input provide contribution the way the runtime does. */
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
|
||||
const state = contribution.hooks!['input'] as {
|
||||
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
|
||||
}
|
||||
const actions = contribution.props!['inputActions'] as {
|
||||
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
|
||||
}
|
||||
return { state, actions }
|
||||
}
|
||||
return {
|
||||
ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, emptySurface,
|
||||
ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
|
||||
}
|
||||
}
|
||||
@@ -163,52 +184,60 @@ describe('conversation slot inject surface', () => {
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
|
||||
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', 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')
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
|
||||
actions.setDraft(' ')
|
||||
actions.submit('queue')
|
||||
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
||||
expect(instance.store.getSnapshot().draft).toBe(' ')
|
||||
expect(state.getSnapshot().draft).toBe(' ')
|
||||
// Success: cleared and stays cleared.
|
||||
instance.actions.setDraft('hello')
|
||||
injected.send('hello', 'queue')
|
||||
expect(instance.store.getSnapshot().draft).toBe('')
|
||||
actions.setDraft('hello')
|
||||
actions.submit('queue')
|
||||
expect(state.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')
|
||||
actions.setDraft('retry me')
|
||||
actions.submit('queue')
|
||||
await vi.waitFor(() => {
|
||||
expect(instance.store.getSnapshot().draft).toBe('retry me')
|
||||
expect(state.getSnapshot().draft).toBe('retry me')
|
||||
})
|
||||
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
|
||||
// Failure landing after new typing: no clobber (restore 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')
|
||||
actions.submit('queue')
|
||||
actions.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
|
||||
expect(state.getSnapshot().draft).toBe('typed during flight')
|
||||
// The provide contribution is idempotent per session: one shell identity.
|
||||
expect(b.inputSurface(ROOT).state).toBe(state)
|
||||
// The draft mirror rides the conversation inject face.
|
||||
const mirrored: string[] = []
|
||||
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
|
||||
actions.setDraft('mirrored text')
|
||||
expect(mirrored).toEqual(['mirrored text'])
|
||||
unbind()
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
|
||||
injected.stop()
|
||||
b.composerSurface(ROOT).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
|
||||
const entry = b.entryOf('conversation.composer.bar')
|
||||
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
|
||||
// Unknown session: sessions.scope answers nothing.
|
||||
;(b.sessionsFake.scope as unknown) = () => undefined
|
||||
expect(() => injectFn(ROOT, instance.actions)).toThrow(/resolved no scope/)
|
||||
expect(() => injectFn(ROOT).stop()).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/)
|
||||
expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/)
|
||||
})
|
||||
|
||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||
@@ -223,15 +252,28 @@ describe('conversation slot inject surface', () => {
|
||||
expect(conv.instance).toBe(instance)
|
||||
})
|
||||
|
||||
it('routes navigation through SessionsService and the retained prompt through the scoped Session', async () => {
|
||||
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const resident = b.residentSurface(ROOT)
|
||||
injected.open(ROOT)
|
||||
injected.updateSessionPrompt('revised')
|
||||
injected.retrySessionPrompt()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.updatePendingPrompt).toHaveBeenCalledWith('revised')
|
||||
expect(b.sessionFake.retryPendingPrompt).toHaveBeenCalledOnce()
|
||||
// Same-session connect (the picked workspace resolves to this session):
|
||||
// no draft movement, plain re-open.
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
actions.setDraft('carry me')
|
||||
resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
|
||||
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
|
||||
expect(state.getSnapshot().draft).toBe('carry me')
|
||||
// Cross-session connect: the draft MOVES — the old machine empties, the
|
||||
// new session's machine receives the text, then navigation lands there.
|
||||
const OTHER = 'other-1' as SessionId
|
||||
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
|
||||
resident.selectWorkspace('workspace-2' as never)
|
||||
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
})
|
||||
|
||||
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
||||
@@ -266,23 +308,9 @@ describe('details inject surface', () => {
|
||||
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 conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT)
|
||||
const details = b.hostFace.storeOf(entry, ROOT)
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('empty state injects the runtime intent actions and remains storeless', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.empty')
|
||||
expect(entry.store).toBeUndefined()
|
||||
const injected = b.emptySurface()
|
||||
injected.startSession(undefined, 'fresh')
|
||||
injected.startSession('workspace-1' as never, 'retargeted')
|
||||
injected.updateSessionPrompt('typed')
|
||||
injected.sendSession()
|
||||
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(1, undefined, 'fresh')
|
||||
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(2, 'workspace-1', 'retargeted')
|
||||
expect(b.sessionsFake.updateIntent).toHaveBeenCalledWith('typed')
|
||||
expect(b.workspacesFake.sendSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user