feat: slash system / input service / agent scope

This commit is contained in:
imccyu
2026-07-27 03:17:52 +08:00
parent f3a4833dbf
commit a27be43ac1
210 changed files with 15969 additions and 2213 deletions
@@ -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()
})
})