246 lines
8.4 KiB
TypeScript
246 lines
8.4 KiB
TypeScript
import { Context } from '@deepseek-ai/cordis'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
import {
|
|
createScope, MutableSessionEventSource,
|
|
} from '@deepseek-ai/dsh-api-session-controller/client'
|
|
import type {
|
|
ISessions, SessionBinding, SessionFace, SessionListState, SessionSnapshot,
|
|
} from '@deepseek-ai/dsh-api-session-controller/client'
|
|
import {
|
|
ConversationEventRegistry, ConversationNodeAssembler, ConversationViewRegistry, UiConversation,
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import type {
|
|
ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode,
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
const SESSION_ID = 'resident' as SessionId
|
|
|
|
function sessionSnapshot(): SessionSnapshot {
|
|
return {
|
|
sessionId: SESSION_ID,
|
|
queue: [],
|
|
running: false,
|
|
subagent: null,
|
|
removed: false,
|
|
openState: 'open',
|
|
openError: null,
|
|
hasMore: false,
|
|
loadingOlder: false,
|
|
promptError: null,
|
|
blank: true,
|
|
lastAgentError: null,
|
|
promptAttempted: false,
|
|
awaitingFirstTurn: false,
|
|
}
|
|
}
|
|
|
|
function fakeSession(): SessionFace {
|
|
const snapshot = createSnapshotStore(sessionSnapshot())
|
|
return {
|
|
sessionId: SESSION_ID,
|
|
projections: { faceOf: () => createSnapshotStore<unknown>(undefined) },
|
|
getSnapshot: () => snapshot.getSnapshot(),
|
|
subscribe: listener => snapshot.subscribe(listener),
|
|
prompt: () => Promise.reject(new Error('unused fake Session operation')),
|
|
readAttachment: () => Promise.reject(new Error('unused fake Session operation')),
|
|
updateQueue: () => Promise.reject(new Error('unused fake Session operation')),
|
|
cancel: () => Promise.reject(new Error('unused fake Session operation')),
|
|
rename: () => Promise.reject(new Error('unused fake Session operation')),
|
|
loadOlder: () => Promise.reject(new Error('unused fake Session operation')),
|
|
command: () => Promise.reject(new Error('unused fake Session operation')),
|
|
}
|
|
}
|
|
|
|
function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBinding } {
|
|
const scope = createScope(ctx, SESSION_ID)
|
|
const binding: SessionBinding = {
|
|
sessionId: SESSION_ID,
|
|
session: fakeSession(),
|
|
eventSource: new MutableSessionEventSource(),
|
|
ctx: scope.ctx,
|
|
}
|
|
const list = createSnapshotStore<SessionListState>({
|
|
ids: [],
|
|
byId: {},
|
|
current: undefined,
|
|
phase: 'ready',
|
|
subagentsByParent: {},
|
|
jobsBySession: {},
|
|
currentAddress: undefined,
|
|
})
|
|
const sessions = {
|
|
list,
|
|
searchResultLimit: 50,
|
|
create: () => Promise.reject(new Error('unused fake Sessions operation')),
|
|
open: () => {},
|
|
openSubagent: () => {},
|
|
subagentAddress: () => undefined,
|
|
setSubagentCatalogOpen: () => {},
|
|
refreshSubagents: () => Promise.reject(new Error('unused fake Sessions operation')),
|
|
clear: () => {},
|
|
refresh: () => Promise.reject(new Error('unused fake Sessions operation')),
|
|
search: () => Promise.reject(new Error('unused fake Sessions operation')),
|
|
fork: () => Promise.reject(new Error('unused fake Sessions operation')),
|
|
scope: id => id === SESSION_ID ? binding.ctx : undefined,
|
|
scopeOf: candidate => candidate === binding.ctx ? SESSION_ID : undefined,
|
|
sessionOf: candidate => candidate === binding.ctx ? binding.session : undefined,
|
|
binding: id => id === SESSION_ID ? binding : undefined,
|
|
} satisfies ISessions
|
|
return { sessions, binding }
|
|
}
|
|
|
|
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
|
|
uiConversation: UiConversation
|
|
binding: SessionBinding
|
|
events: ConversationEventRegistry
|
|
views: ConversationViewRegistry
|
|
}> {
|
|
const ctx = new Context()
|
|
const { sessions, binding } = fakeSessions(ctx)
|
|
const uiConversation = new UiConversation(ctx, sessions)
|
|
return {
|
|
ctx,
|
|
uiConversation,
|
|
binding,
|
|
events: uiConversation.events,
|
|
views: uiConversation.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(['uiConversation'], (featureCtx) => {
|
|
featureCtx.uiConversation.events.register(eventDefinition('message'))
|
|
featureCtx.uiConversation.events.registerFallback(eventDefinition('unknown'))
|
|
featureCtx.uiConversation.views.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 one turn of registry changes into one rebuild per resident Conversation', async () => {
|
|
const { uiConversation, binding, events, views } = await bootRegistries()
|
|
uiConversation.binding(binding)
|
|
const rebuild = vi.spyOn(ConversationNodeAssembler.prototype, 'rebuildRegistry')
|
|
|
|
events.register(eventDefinition('message'))
|
|
views.register(viewDefinition('chat'))
|
|
events.register(eventDefinition('tool'))
|
|
expect(rebuild).not.toHaveBeenCalled()
|
|
|
|
await Promise.resolve()
|
|
expect(rebuild).toHaveBeenCalledOnce()
|
|
|
|
views.register(viewDefinition('trajectory'))
|
|
await Promise.resolve()
|
|
expect(rebuild).toHaveBeenCalledTimes(2)
|
|
rebuild.mockRestore()
|
|
})
|
|
})
|