| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552 |
- import { Context } from '@deepseek-ai/cordis'
- 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'
- 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: [],
- pendingSubmissions: [],
- 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 {
- ctx.provide('slots', { bindStoreScope: vi.fn() } as never)
- return new UiSession(ctx, bench.sessions)
- }
- afterEach(() => {
- vi.restoreAllMocks()
- })
- describe('UiSession bindings', () => {
- it('binds each materialized Session to renderer-owned Store cleanup', () => {
- const ctx = new Context()
- const bench = createSessionsBench(ctx)
- const bindStoreScope = vi.fn()
- ctx.provide('slots', { bindStoreScope } as never)
- const service = new UiSession(ctx, bench.sessions)
- const binding = bench.binding(sessionId('s1'))
- const materialized = service.adapter.resolve(binding.sessionId)
- expect(bindStoreScope).toHaveBeenCalledOnce()
- expect(bindStoreScope).toHaveBeenCalledWith(materialized)
- })
- 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 delegate = (): Promise<void> => Promise.resolve()
- const removeApproval = registerApproval(approval, delegate)
- expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
- const removeDuplicate = registerApproval(duplicate, delegate)
- expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
- const removeQuestion = registerQuestion(question, delegate)
- expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question)
- const removePlan = registerQuestion(plan, delegate)
- expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
- const removeBackground = registerBackground(background, delegate)
- 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 delegate = () => Promise.resolve()
- const remove = registerPendingInteraction(interaction, delegate)
- expect(() => { registerPendingInteraction(interaction, delegate) })
- .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,
- )
- })
- it('removes active values before awaiting their teardown delegation', async () => {
- const ctx = new Context()
- const bench = createSessionsBench(ctx)
- const service = createUiSession(ctx, bench)
- const gate = Promise.withResolvers<undefined>()
- const delegate = vi.fn(() => gate.promise)
- const publish = service.registerPendingInteraction<SessionPendingInteractionBase>(() => 1)
- const remove = publish(
- { key: 'question:1', kind: 'question', sessionId: sessionId('s1') },
- delegate,
- )
- let disposed = false
- const disposal = ctx.fiber.dispose().then(() => { disposed = true })
- await vi.waitFor(() => { expect(delegate).toHaveBeenCalledOnce() })
- expect(service.pendingInteractions.getSnapshot()).toEqual(new Map())
- expect(disposed).toBe(false)
- remove()
- remove()
- gate.resolve(undefined)
- await disposal
- expect(disposed).toBe(true)
- })
- })
- 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', () => {
- expect(() => { nodeApply() }).not.toThrow()
- })
- })
|