Files
deepseek-harness/packages/client/ui-session/tests/ui-session.client.spec.ts
T

516 lines
18 KiB
TypeScript

import { Context } from '@deepseek-ai/cordis'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import type {
AgentContext,
ISessions,
SessionBinding,
SessionListState,
SessionSnapshot,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { Fragment } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
apply,
type SessionPendingInteractionBase,
UiSession,
} from '../src/client/index.ts'
import { apply as nodeApply } from '../src/index.ts'
import * as SessionInvariant from '../src/invariant.ts'
interface SessionsBench {
readonly sessions: ISessions
readonly list: ReturnType<typeof createSnapshotStore<SessionListState>>
readonly resolveBinding: ReturnType<typeof vi.fn<(id: SessionId) => SessionBinding | undefined>>
readonly createSession: ReturnType<typeof vi.fn<ISessions['create']>>
readonly openSession: ReturnType<typeof vi.fn<(id: SessionId) => void>>
readonly clearSession: ReturnType<typeof vi.fn<() => void>>
binding(id: SessionId): SessionBinding
select(id: SessionId | undefined): void
release(id: SessionId): Promise<void>
}
const sessionId = (value: string): SessionId => value as SessionId
function createSessionsBench(_ctx: Context): SessionsBench {
const list = createSnapshotStore<SessionListState>({
ids: [],
byId: {},
current: undefined,
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
})
const bindings = new Map<SessionId, SessionBinding>()
const scopes = new Map<SessionId, Context>()
const resolveBinding = vi.fn((id: SessionId) => bindings.get(id))
const createSession = vi.fn<ISessions['create']>(async options =>
options?.sessionId ?? sessionId(`created-${String(options?.workspaceId ?? 'none')}`))
const openSession = vi.fn((id: SessionId) => {
list.update((draft) => { draft.current = id })
})
const clearSession = vi.fn(() => {
list.update((draft) => { draft.current = undefined })
})
const sessions = {
list,
create: createSession,
open: openSession,
clear: clearSession,
binding: resolveBinding,
} as unknown as ISessions
return {
sessions,
list,
resolveBinding,
createSession,
openSession,
clearSession,
binding(id) {
const scopeCtx = new Context()
const snapshot = createSnapshotStore<SessionSnapshot>({
sessionId: id,
queue: [],
running: false,
subagent: null,
removed: false,
openState: 'open',
openError: null,
hasMore: false,
loadingOlder: false,
promptError: null,
blank: false,
lastAgentError: null,
promptAttempted: false,
awaitingFirstTurn: false,
})
const projections = new Map<string, HostObservable<unknown>>()
const session = {
sessionId: id,
projections: {
faceOf(key: string) {
let source = projections.get(key)
if (source === undefined) {
source = createSnapshotStore<unknown>(undefined)
projections.set(key, source)
}
return source
},
},
getSnapshot: () => snapshot.getSnapshot(),
subscribe: (listener: () => void) => snapshot.subscribe(listener),
} as unknown as SessionBinding['session']
const binding: SessionBinding = {
sessionId: id,
session,
eventSource: new MutableSessionEventSource(),
ctx: scopeCtx as AgentContext,
}
bindings.set(id, binding)
scopes.set(id, scopeCtx)
list.update((draft) => {
if (!draft.ids.includes(id)) draft.ids.push(id)
draft.byId[id] = {
id,
displayTitle: id,
running: false,
blank: false,
updatedAt: 1,
}
})
return binding
},
select(id) {
list.update((draft) => { draft.current = id })
},
async release(id) {
bindings.delete(id)
const scopeCtx = scopes.get(id)
scopes.delete(id)
await scopeCtx?.fiber.dispose()
},
}
}
function createUiSession(ctx: Context, bench: SessionsBench): UiSession {
return new UiSession(ctx, bench.sessions)
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('UiSession bindings', () => {
it('materializes built-in sources, caches a binding, and publishes selection and release', async () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
const binding = bench.binding(id)
const current = vi.fn()
const offCurrent = service.adapter.current.subscribe(current)
expect(service.adapter.current.getSnapshot()).toEqual({
key: undefined,
hooks: { session: undefined },
keyedHooks: { projection: undefined },
props: { sessionId: undefined },
})
expect(service.adapter.resolve('missing')).toBeUndefined()
const first = service.adapter.resolve(id)!
expect(service.adapter.resolve(id)).toBe(first)
expect(first.key).toBe(id)
expect(first.hooks.session).toBe(binding.session)
expect(first.props.sessionId).toBe(id)
expect(first.keyedHooks.projection?.('status'))
.toBe(binding.session.projections.faceOf('status'))
bench.select(id)
expect(current).toHaveBeenCalledTimes(1)
expect(service.adapter.current.getSnapshot()).toBe(first)
bench.select(id)
expect(current).toHaveBeenCalledTimes(1)
bench.resolveBinding.mockClear()
await bench.release(id)
expect(bench.resolveBinding).not.toHaveBeenCalled()
expect(current).toHaveBeenCalledTimes(2)
expect(service.adapter.current.getSnapshot().key).toBeUndefined()
const other = sessionId('s2')
bench.binding(other)
service.adapter.resolve(other)
bench.resolveBinding.mockClear()
await bench.release(other)
expect(bench.resolveBinding).not.toHaveBeenCalled()
expect(current).toHaveBeenCalledTimes(2)
offCurrent()
await ctx.fiber.dispose()
})
it('renders the empty area and a Session-keyed selected area', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const empty = vi.fn(() => 'empty')
const children = 'session body'
if (service.adapter.renderArea === undefined) throw new Error('Session area renderer was not installed')
const emptyArea = service.adapter.renderArea(
service.adapter.current.getSnapshot(),
{ empty, children },
)
expect(emptyArea).toMatchObject({
type: Fragment,
key: null,
props: { children: 'empty' },
})
expect(empty).toHaveBeenCalledOnce()
const defaultEmptyArea = service.adapter.renderArea(
service.adapter.current.getSnapshot(),
{ children },
)
expect(defaultEmptyArea).toMatchObject({
type: Fragment,
key: null,
props: { children: null },
})
const id = sessionId('s1')
bench.binding(id)
bench.select(id)
const selectedArea = service.adapter.renderArea(
service.adapter.current.getSnapshot(),
{ empty, children },
)
expect(selectedArea).toMatchObject({
type: Fragment,
key: id,
props: { children },
})
expect(empty).toHaveBeenCalledOnce()
})
it('contains a failing current-binding subscriber and continues dispatch', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
bench.binding(id)
const failure = new Error('subscriber failed')
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
service.adapter.current.subscribe(() => { throw failure })
const after = vi.fn()
service.adapter.current.subscribe(after)
bench.select(id)
expect(after).toHaveBeenCalledOnce()
expect(report).toHaveBeenCalledWith(
'[ui-session] current binding subscriber failed:',
failure,
)
})
it('releases cached bindings when the owning Client context stops', async () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
bench.binding(id)
bench.select(id)
service.adapter.current.getSnapshot()
await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
await bench.release(id)
})
it('rebuilds live bindings and removes only the disposed source contribution', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
bench.binding(id)
bench.select(id)
const custom = createSnapshotStore({ value: 1 })
const keyed = (key: string): HostObservable<unknown> => createSnapshotStore(key)
const dispose = service.provide({
hooks: ['custom'],
keyedHooks: ['customKeyed'],
props: ['customProp'],
resolve: () => ({
hooks: { custom },
keyedHooks: { customKeyed: keyed },
props: { customProp: 'value' },
}),
})
const disposeNeighbor = service.provide({
props: ['neighborProp'],
resolve: () => ({ props: { neighborProp: 'neighbor' } }),
})
const contributed = service.adapter.current.getSnapshot()
expect(contributed.hooks.custom).toBe(custom)
expect(contributed.keyedHooks.customKeyed).toBe(keyed)
expect(contributed.props.customProp).toBe('value')
expect(contributed.props.neighborProp).toBe('neighbor')
dispose()
const restored = service.adapter.current.getSnapshot()
expect(restored.hooks.session).toBeDefined()
expect(typeof restored.keyedHooks.projection).toBe('function')
expect(restored.props.sessionId).toBe(id)
expect(restored.hooks).not.toHaveProperty('custom')
expect(restored.props.neighborProp).toBe('neighbor')
dispose()
expect(service.adapter.current.getSnapshot().props.neighborProp).toBe('neighbor')
disposeNeighbor()
expect(service.adapter.current.getSnapshot().props).not.toHaveProperty('neighborProp')
})
it.each([
['hook', { resolve: () => ({ hooks: { surprise: createSnapshotStore(1) } }) }],
['keyed hook', { resolve: () => ({ keyedHooks: { surprise: () => createSnapshotStore(1) } }) }],
['prop', { resolve: () => ({ props: { surprise: 1 } }) }],
] as const)('rejects an undeclared %s returned by a contribution', (kind, descriptor) => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
expect(() => { service.provide(descriptor as never) })
.toThrow(`uiSession.provide: undeclared ${kind} 'surprise'`)
})
it.each([
['hook', { hooks: ['missing'], resolve: () => ({}) }],
['keyed hook', { keyedHooks: ['missing'], resolve: () => ({}) }],
['prop', { props: ['missing'], resolve: () => ({}) }],
] as const)('rejects a missing declared %s', (kind, descriptor) => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
expect(() => { service.provide(descriptor) })
.toThrow(`uiSession.provide: missing ${kind} 'missing'`)
})
it.each([
['hook', { hooks: ['session'], resolve: () => ({ hooks: { session: createSnapshotStore(1) } }) }],
['keyed hook', {
keyedHooks: ['projection'],
resolve: () => ({ keyedHooks: { projection: () => createSnapshotStore(1) } }),
}],
['prop', { props: ['sessionId'], resolve: () => ({ props: { sessionId: 'other' } }) }],
] as const)('rejects a duplicate declared %s', (kind, descriptor) => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
expect(() => { service.provide(descriptor) })
.toThrow(`uiSession.provide: duplicate ${kind}`)
})
it('rejects cross-compartment collisions at the final standard prop name', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const source = createSnapshotStore(1)
service.provide({
hooks: ['feature'],
resolve: () => ({ hooks: { feature: source } }),
})
const before = service.adapter.current.getSnapshot()
expect(() => service.provide({
keyedHooks: ['feature'],
resolve: () => ({ keyedHooks: { feature: () => source } }),
})).toThrow("uiSession.provide: duplicate keyed hook 'feature' at prop 'useFeature'")
expect(() => service.provide({
props: ['useFeature'],
resolve: () => ({ props: { useFeature: true } }),
})).toThrow("uiSession.provide: duplicate prop 'useFeature' at prop 'useFeature'")
expect(service.adapter.current.getSnapshot()).toBe(before)
})
it('releases partially rebuilt bindings when a later Session contribution fails', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
service.adapter.resolve(bench.binding(sessionId('s2')).sessionId)
let calls = 0
expect(() => service.provide({
props: ['partial'],
resolve: () => {
calls += 1
if (calls === 2) throw new Error('second binding failed')
return { props: { partial: true } }
},
})).toThrow('second binding failed')
expect(calls).toBe(2)
expect(service.adapter.resolve(sessionId('s1'))?.props).not.toHaveProperty('partial')
})
})
describe('UiSession pending interactions', () => {
it('publishes the highest-precedence exact object and removes each source independently', async () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
const listener = vi.fn()
const off = service.pendingInteractions.subscribe(listener)
const registerApproval = service.registerPendingInteraction<SessionPendingInteractionBase>(
() => 0,
)
const registerQuestion = service.registerPendingInteraction<SessionPendingInteractionBase>(
interaction => interaction.kind === 'plan-review' ? 2 : 1,
)
const registerBackground = service.registerPendingInteraction<SessionPendingInteractionBase>(
() => -1,
)
listener.mockClear()
const approval = { key: 'approval:1', kind: 'approval', sessionId: id }
const duplicate = { key: 'approval:2', kind: 'approval', sessionId: id }
const question = { key: 'question:1', kind: 'question', sessionId: id }
const plan = { key: 'question:2', kind: 'plan-review', sessionId: id }
const background = { key: 'background:1', kind: 'background', sessionId: id }
const removeApproval = registerApproval(approval)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
const removeDuplicate = registerApproval(duplicate)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
const removeQuestion = registerQuestion(question)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question)
const removePlan = registerQuestion(plan)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
const removeBackground = registerBackground(background)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
removeBackground()
removeQuestion()
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
removePlan()
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
removeDuplicate()
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
removeApproval()
removeApproval()
expect(service.pendingInteractions.getSnapshot().has(id)).toBe(false)
off()
await ctx.fiber.dispose()
})
it('rejects duplicate keys and contains a failing aggregate subscriber', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const registerPendingInteraction = service.registerPendingInteraction<SessionPendingInteractionBase>(
() => 1,
)
const interaction = { key: 'question:1', kind: 'question', sessionId: sessionId('s1') }
const remove = registerPendingInteraction(interaction)
expect(() => { registerPendingInteraction(interaction) })
.toThrow("ui-session: duplicate pending interaction key 'question:1'")
const failure = new Error('pending subscriber failed')
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
service.pendingInteractions.subscribe(() => { throw failure })
const after = vi.fn()
service.pendingInteractions.subscribe(after)
remove()
expect(after).toHaveBeenCalledOnce()
expect(report).toHaveBeenCalledWith(
'[ui-session] pending interactions subscriber failed:',
failure,
)
})
})
describe('ui-session apply', () => {
it('provides the root sources and installs the Session scope adapter', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const slots = {
provideRoot: vi.fn(),
installScope: vi.fn(),
}
ctx.provide('sessions', bench.sessions)
ctx.provide('slots', slots as never)
apply(ctx)
expect(ctx.uiSession).toBeInstanceOf(UiSession)
expect(slots.provideRoot).toHaveBeenCalledWith({
hooks: {
sessions: bench.sessions.list,
sessionPendingInteraction: ctx.uiSession.pendingInteractions,
},
})
expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter)
})
it('keeps the Host loader half inert and registers the invariant companion', async () => {
expect(() => { nodeApply() }).not.toThrow()
const ctx = new Context()
await ctx.plugin(InvariantRegistry, { enabled: true })
await expect(ctx.plugin(SessionInvariant).await()).resolves.toBeDefined()
})
})