`CommandService.list` and `execute` carry the wire contract directly through `@Remote`, and the Client assembly mounts the generated commands contribution. The legacy API Proxy route, its schemas, the map rows, the generated client methods and the fixture's command domain are removed, so the catalog and the admission call have one owner again. `Session.command()` keeps a result-shaped public face for parity with the prompt, cancel and attachment neighbours it sits beside, and reads the generated namespace through one `SessionRemotes` parameter. The Session cluster declares that face against the owning business package rather than the generated contribution: the Host compiler aggregate builds this package, and it runs before any contribution is emitted. Migrated calls lose the `title-invalid` class of protocol-only error codes and report `internal`; no production caller branched on them.
162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
import { Context } from '@deepseek-ai/cordis'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
|
import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts'
|
|
import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts'
|
|
import type {
|
|
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
|
|
} from '../src/client/contract/conversation.ts'
|
|
import { Session } from '../src/client/sessions/session.ts'
|
|
import { SessionsService } from '../src/client/sessions/service.ts'
|
|
import { FakeApiClient, fakeRemote, ok } from './fake-api.ts'
|
|
|
|
function eventDefinition(kind: string): ConversationNodeDefinition<null> {
|
|
return {
|
|
kind,
|
|
target: 'chat',
|
|
match: () => null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
buildViewNode: () => null,
|
|
}
|
|
}
|
|
|
|
function viewDefinition(target: string): ConversationViewDefinition<ConversationViewNode, null> {
|
|
return {
|
|
target,
|
|
create: () => ({
|
|
empty: null,
|
|
replace: () => null,
|
|
apply: () => null,
|
|
}),
|
|
}
|
|
}
|
|
|
|
async function bootRegistries(): Promise<{
|
|
ctx: Context
|
|
events: ConversationEventRegistry
|
|
views: ConversationViewRegistry
|
|
}> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(ConversationEventRegistry).await()
|
|
await ctx.plugin(ConversationViewRegistry).await()
|
|
const events = ctx.get('conversationEvents') as ConversationEventRegistry
|
|
const views = ctx.get('conversationViews') as ConversationViewRegistry
|
|
return { ctx, events, views }
|
|
}
|
|
|
|
describe('Conversation registries', () => {
|
|
it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => {
|
|
const { events } = await bootRegistries()
|
|
const definition = eventDefinition('message')
|
|
const dispose = events.register(definition)
|
|
|
|
expect(events.entries()).toEqual([definition])
|
|
expect(() => events.register(eventDefinition('message'))).toThrow(/already registered/)
|
|
|
|
dispose()
|
|
dispose()
|
|
expect(events.entries()).toEqual([])
|
|
})
|
|
|
|
it('rejects a duplicate fallback and clears it through its idempotent disposer', async () => {
|
|
const { events } = await bootRegistries()
|
|
const fallback = eventDefinition('unknown')
|
|
const dispose = events.registerFallback(fallback)
|
|
|
|
expect(events.fallbackEntry()).toBe(fallback)
|
|
expect(() => events.registerFallback(eventDefinition('other'))).toThrow(/already registered/)
|
|
|
|
dispose()
|
|
dispose()
|
|
expect(events.fallbackEntry()).toBeUndefined()
|
|
})
|
|
|
|
it('rejects rendering Definitions that omit either target or builder', async () => {
|
|
const { events } = await bootRegistries()
|
|
const targetOnly: ConversationNodeDefinition<null> = {
|
|
kind: 'target-only',
|
|
target: 'chat',
|
|
match: () => null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
}
|
|
const builderOnly: ConversationNodeDefinition<null> = {
|
|
kind: 'builder-only',
|
|
match: () => null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
buildViewNode: () => null,
|
|
}
|
|
|
|
expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/)
|
|
expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/)
|
|
})
|
|
|
|
it('rejects a State-only Definition as the unmatched-event fallback', async () => {
|
|
const { events } = await bootRegistries()
|
|
const fallback: ConversationNodeDefinition<null> = {
|
|
kind: 'state-only-fallback',
|
|
match: () => null,
|
|
start: () => null,
|
|
update: context => context.state,
|
|
}
|
|
|
|
expect(() => events.registerFallback(fallback))
|
|
.toThrow('conversation fallback Definition must declare a target')
|
|
})
|
|
|
|
it('rejects duplicate view targets and disposes a view registration once', async () => {
|
|
const { views } = await bootRegistries()
|
|
const definition = viewDefinition('chat')
|
|
const dispose = views.register(definition)
|
|
|
|
expect(views.entries()).toEqual([definition])
|
|
expect(() => views.register(viewDefinition('chat'))).toThrow(/already registered/)
|
|
|
|
dispose()
|
|
dispose()
|
|
expect(views.entries()).toEqual([])
|
|
})
|
|
|
|
it('removes Event, fallback, and view contributions with their caller fiber', async () => {
|
|
const { ctx, events, views } = await bootRegistries()
|
|
const feature = ctx.inject(['conversationEvents', 'conversationViews'], (featureCtx) => {
|
|
featureCtx.conversationEvents.register(eventDefinition('message'))
|
|
featureCtx.conversationEvents.registerFallback(eventDefinition('unknown'))
|
|
featureCtx.conversationViews.register(viewDefinition('chat'))
|
|
})
|
|
await feature.await()
|
|
|
|
expect(events.entries()).toHaveLength(1)
|
|
expect(events.fallbackEntry()).toBeDefined()
|
|
expect(views.entries()).toHaveLength(1)
|
|
|
|
await feature.dispose()
|
|
expect(events.entries()).toEqual([])
|
|
expect(events.fallbackEntry()).toBeUndefined()
|
|
expect(views.entries()).toEqual([])
|
|
})
|
|
|
|
it('coalesces registry changes into one rebuild of every resident Session', async () => {
|
|
const { ctx, events, views } = await bootRegistries()
|
|
const api = new FakeApiClient()
|
|
const sessionId = 'resident' as SessionId
|
|
api.onList = () => Promise.resolve(ok({
|
|
items: [{ sessionId, updatedAt: 1, running: false, blank: true }],
|
|
}) as never)
|
|
const sessions = new SessionsService(ctx, api, fakeRemote())
|
|
await sessions.refresh()
|
|
await Promise.resolve()
|
|
sessions.scope(sessionId)
|
|
const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry')
|
|
|
|
events.register(eventDefinition('message'))
|
|
views.register(viewDefinition('chat'))
|
|
await Promise.resolve()
|
|
|
|
expect(rebuild).toHaveBeenCalledOnce()
|
|
rebuild.mockRestore()
|
|
})
|
|
})
|