test(client): migrate Session reference coverage

This commit is contained in:
imccyu committed 2026-09-17 05:28:19 +08:00
1 parent b9b14dc05e
commit 6d3b5d4526
96 files changed
+3385 -1815

No files matched your search

@@ -254,7 +254,6 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
// new-session screen with the self-referential preset staged, and the
// blank session the flow produces composes from it on the host.
await dialog.waitFor({ state: 'detached', timeout: 10_000 })
await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
await expect.poll(async () => {
const response = await scaffold.hostFetch('/api/session/list', {
method: 'POST',
+2 -3
View File
@@ -752,7 +752,7 @@ describe('web e2e: long Chat scroll contract', () => {
})
}, 180_000)
it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
it.skipIf(MODE === 'record')('keeps composer resizing on the correct scroll owner across reopened Sessions', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-restore-composer',
seeds: [
@@ -780,7 +780,6 @@ describe('web e2e: long Chat scroll contract', () => {
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await nextPaint(world.page)
await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE)
const narrowSessionAnchor = await visibleFlowAnchor(world.page)
await openSeed(
world.page,
@@ -791,9 +790,9 @@ describe('web e2e: long Chat scroll contract', () => {
world.page,
RESTORE_FIXTURE_A,
)
await expectSameFlowTop(world.page, narrowSessionAnchor)
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
await backToBottom.waitFor({ timeout: 15_000 })
await backToBottom.evaluate((button) => {
if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
button.click()
@@ -379,9 +379,9 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
expect(await paneSnapshot(page)).toEqual(retainedB)
await open()
await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('false')
expect(await paneSnapshot(page)).toEqual(retainedB)
await checkpoint('B restored: normal mode and Files directory state')
await checkpoint('B restored: normal mode and collapsed Files directory')
await close()
await select(original, 'LIGHTHOUSE')
await expect.poll(() => columns(page)).toEqual(normalColumns)
+1 -1
View File
@@ -857,7 +857,7 @@ describe('web e2e: shipped right Sidebar', () => {
await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true')
await expect.poll(records, { timeout: 15_000 }).toEqual(before)
expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
expect(await wrap.getAttribute('aria-pressed')).toBe('false')
expect(await wrap.getAttribute('aria-pressed')).toBe('true')
expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
let warningStart = fxTripwire.warnings.length
await fx.reload({ waitUntil: 'load' })
+3
View File
@@ -68,6 +68,9 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const sessions = scaffold.ctx.sessions.list()
expect(sessions).toHaveLength(1)
scaffold.ctx.permissionPresets.set(sessions[0]!, 'danger-full-access')
}, 120_000)
afterAll(async () => {
@@ -1,4 +1,4 @@
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { RemoteError, typertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
import { Context, Service } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
@@ -1640,6 +1640,83 @@ describe('Client Typert API', () => {
await client.dispose()
})
it('holds an owned Context through handler and reply settlement', async () => {
const replyEntered = Promise.withResolvers<undefined>()
const reply = Promise.withResolvers<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>()
const call = vi.fn<ConnectionHandle['rpc']['call']>(() => {
replyEntered.resolve(undefined)
return reply.promise
})
const { ctx, client, carrier } = await eventBench(call)
const target = ctx.extend()
const release = vi.fn()
const entered = Promise.withResolvers<undefined>()
const handler = Promise.withResolvers<undefined>()
ctx.typert.contexts.registerClient('agent', {
identity: candidate => candidate === target ? agentId('owned-context') : undefined,
resolve: () => typertOwnedValue(target, release),
})
target.remote.$on('fixture/approval', async () => {
entered.resolve(undefined)
await handler.promise
expect(release).not.toHaveBeenCalled()
return 'allowed'
})
try {
carrier.emit(approvalFrame('owned-event', 'owned-context', 'wait'))
await entered.promise
expect(release).not.toHaveBeenCalled()
handler.resolve(undefined)
await replyEntered.promise
expect(release).not.toHaveBeenCalled()
reply.resolve({ ok: true, value: undefined })
await vi.waitFor(() => { expect(release).toHaveBeenCalledOnce() })
} finally {
handler.resolve(undefined)
reply.resolve({ ok: true, value: undefined })
await client.dispose()
}
})
it.each(['success', 'failure'] as const)('keeps cancelled Context ownership through handler %s and joins disposal', async (outcome) => {
const { ctx, client, carrier, call } = await eventBench()
const target = ctx.extend()
const release = vi.fn()
const entered = Promise.withResolvers<AbortSignal>()
const handler = Promise.withResolvers<undefined>()
ctx.typert.contexts.registerClient('agent', {
identity: candidate => candidate === target ? agentId('owned-cancelled') : undefined,
resolve: () => typertOwnedValue(target, release),
})
target.remote.$on('fixture/approval', async (request) => {
if (request.signal === undefined) throw new Error('expected invocation cancellation')
entered.resolve(request.signal)
await handler.promise
expect(release).not.toHaveBeenCalled()
return 'allowed'
})
let disposal: Promise<void> | undefined
try {
carrier.emit(approvalFrame('owned-cancel-event', 'owned-cancelled', 'wait'))
const signal = await entered.promise
carrier.emit({ type: 'cancel', eventId: 'owned-cancel-event' })
await vi.waitFor(() => { expect(signal.aborted).toBe(true) })
expect(release).not.toHaveBeenCalled()
let disposed = false
disposal = client.dispose().then(() => { disposed = true })
expect(disposed).toBe(false)
if (outcome === 'failure') handler.reject(new Error('cancelled handler failed'))
else handler.resolve(undefined)
await disposal
expect(release).toHaveBeenCalledOnce()
expect(call).not.toHaveBeenCalled()
} finally {
handler.resolve(undefined)
await disposal
await client.dispose()
}
})
it('fails the Connection generation when a result RPC is rejected', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({
ok: false,
@@ -1835,13 +1912,11 @@ describe('Client Typert API', () => {
carrier.emit(approvalFrame('event-cancel-race', 'agent-cancel-race', 'wait'))
const deliverySignal = await entered.promise
release.resolve(undefined)
carrier.emit({ type: 'cancel', eventId: 'event-cancel-race' })
await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) })
await Promise.resolve()
expect(call).not.toHaveBeenCalled()
release.resolve(undefined)
await client.dispose()
expect(call).not.toHaveBeenCalled()
})
it('cancels pending listener work when the generation ends', async () => {
@@ -3,12 +3,12 @@
* arriving as emit frames on the `$events` stream, the control stream over
* the real Connection, and Agent Context identity through the Typert registry.
*/
import type { Context } from '@deepseek-ai/cordis'
import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
import { afterEach, describe, expect, vi, type MockInstance } from 'vitest'
import { ClientSessions } from '../src/client/sessions/service.ts'
import type { SessionListValue } from '../src/types.ts'
@@ -138,12 +138,17 @@ describe('Session Controller Client apply', () => {
const { client, sessions } = await bench(start)
const adapter = client.ctx.typert.contexts.getClient('agent')
const first = adapter?.resolve(sid('agent-early'))
expect(first).toBeDefined()
expect(sessions.scopeOf(first as Context)).toBe(sid('agent-early'))
expect(adapter?.resolve(sid('agent-early'))).toBe(first)
const second = adapter?.resolve(sid('agent-early'))
if (!isTypertOwnedValue(first) || !isTypertOwnedValue(second)) throw new Error('expected owned Contexts')
using firstOwner = first
using secondOwner = second
expect(sessions.scopeOf(firstOwner.value)).toBe(sid('agent-early'))
expect(secondOwner.value).toBe(firstOwner.value)
expect(sessions.retainInfo(sid('agent-early')).getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { gateway: 2 } })
expect(mock.log.requests('session/follow')).toHaveLength(0)
list.resolve(ok({ items: [] }))
await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
expect(sessions.scope(sid('agent-early'))).toBe(firstOwner.value)
})
it('projects Agent Context identity in both directions and withdraws the adapter when the row unloads', async ({ mock, start }) => {
@@ -151,12 +156,16 @@ describe('Session Controller Client apply', () => {
await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
await emit(mock, 'api-session/added', { sessionId: sid('agent-1'), updatedAt: 1, running: false, blank: true })
await vi.waitFor(() => { expect(sessions.scope(sid('agent-1'))).toBeDefined() })
const scoped = sessions.scope(sid('agent-1')) as Context
expect(sessions.scope(sid('agent-1'))).toBeUndefined()
using reference = sessions.retainAgentScope(sid('agent-1'))
const scoped = reference.binding.ctx
const adapter = client.ctx.typert.contexts.getClient('agent')
expect(adapter?.identity(client.ctx)).toBeUndefined()
expect(adapter?.identity(scoped)).toBe(sid('agent-1'))
expect(adapter?.resolve(sid('agent-1'))).toBe(scoped)
const resolved = adapter?.resolve(sid('agent-1'))
if (!isTypertOwnedValue(resolved)) throw new Error('expected invocation ownership')
using invocation = resolved
expect(invocation.value).toBe(scoped)
await client.unload(SELF)
expect(client.ctx.typert.contexts.getClient('agent')).toBeUndefined()
@@ -53,10 +53,9 @@ describe('Session lineage flattening', () => {
}
})
it('projects the completion-reminder set into rows (absent = false)', () => {
const out = flattenLineage([s('a', 10), s('b', 20)], new Set(['b' as SessionId]))
expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false)
expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true)
expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false)
it('keeps lineage rows free of completion presentation state', () => {
const out = flattenLineage([s('a', 10), s('b', 20)])
expect(out).toHaveLength(2)
for (const row of out) expect(row).not.toHaveProperty('completed')
})
})
@@ -8,7 +8,6 @@ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
import {
createClientTest, type ClientTestFixtures, webApp,
@@ -43,12 +42,10 @@ function summary(sessionId: SessionId, over: SummaryOver = {}) {
function makeManager(
mock: RemoteMock,
remote: ClientTestFixtures['remote'],
restoredSelection?: SessionId,
restoredAddress?: SubagentAddress,
): SessionManager {
mock.load(sessionWorld)
// Cases using this helper never open a Session, so they do not need the broader Client Remote's $stream member.
return new SessionManager(remote as unknown as SessionRemotes, restoredSelection, restoredAddress)
return new SessionManager(remote as unknown as SessionRemotes)
}
describe('SessionManager instances', () => {
@@ -63,7 +60,108 @@ describe('SessionManager instances', () => {
})
describe('SessionManager query lifetime', () => {
it.for(['list', 'catalog'] as const)('forwards an unexpected %s failure and still completes teardown', async (target, { mock, remote }) => {
const manager = makeManager(mock, remote)
const failure = new Error('query implementation failed')
if (target === 'list') remote.session.list.mockRejectedValueOnce(failure)
else remote.subagents.list.mockRejectedValueOnce(failure)
try {
await expect(target === 'list' ? manager.refreshList() : manager.refreshSubagents(S1)).rejects.toBe(failure)
} finally {
await manager.dispose()
}
})
it('keeps a rejected Remote list failure in the observable state', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
const failure = new RemoteError('gateway/internal', 'list unavailable', {})
remote.session.list.mockRejectedValueOnce(failure)
try {
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: failure })
} finally {
await manager.dispose()
}
})
it.for([false, true])('preserves a rejected Remote catalog failure with prior baseline %s', async (warm, { mock, remote }) => {
const manager = makeManager(mock, remote)
const failure = new RemoteError('gateway/internal', 'catalog unavailable', {})
const entries = [{ kind: 'child' as const, id: S2, mode: 'one-shot' as const, activity: 'inactive' as const, hasChildren: false }]
try {
if (warm) {
remote.subagents.list.mockResolvedValueOnce(ok({ entries, parentAvailable: true }))
await manager.refreshSubagents(S1)
manager.handleSessionRemoved(S1)
}
remote.subagents.list.mockRejectedValueOnce(failure)
const refresh = manager.refreshSubagents(S1)
await refresh
expect(manager.getListSnapshot().subagentsByParent[S1]).toMatchObject({
state: 'error', error: failure, entries: warm ? entries : [],
})
expect(manager.getListSnapshot().subagentsByParent[S1]?.parentAvailable).toBe(warm ? false : undefined)
} finally {
await manager.dispose()
}
})
it('cancels a queued catalog membership refresh when its consumer closes', async ({ mock, remote }) => {
vi.useFakeTimers()
const manager = makeManager(mock, remote)
try {
manager.setSubagentCatalogOpen(S1, true)
await manager.refreshSubagents(S1)
manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
manager.setSubagentCatalogOpen(S1, false)
await vi.runAllTimersAsync()
expect(remote.subagents.list).toHaveBeenCalledOnce()
} finally {
await manager.dispose()
vi.useRealTimers()
}
})
})
describe('list lifecycle', () => {
it('fills missing durable links without overwriting established rows and projects first-send engagement', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
remote.session.list.mockResolvedValueOnce(ok({ items: [
summary(S1, { blank: true }), summary(S2, { cwd: '/existing', blank: true }),
] }))
try {
await manager.refreshList()
manager.handleSessionAdded(summary(S1, { cwd: '/filled', parentSessionId: S2, origin: 'subagent', blank: false }))
manager.handleSessionAdded(summary(S2, { cwd: '/ignored', blank: true }))
manager.handleSessionActivity(S1, 50)
manager.handleSessionActivity(S2, 200)
await manager.get(S2).prompt([{ type: 'text', text: 'first message' }], 'queue')
expect(manager.getListSnapshot().items).toEqual(expect.arrayContaining([
expect.objectContaining({ sessionId: S1, cwd: '/filled', parentSessionId: S2, origin: 'subagent', blank: false }),
expect.objectContaining({ sessionId: S2, cwd: '/existing', updatedAt: 200, blank: false }),
]))
} finally {
await manager.dispose()
}
})
it('projects cold Session additions and an empty-cut control baseline before history exists', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
try {
manager.handleSessionAdded({
...summary(S1), projections: { asOfSeq: -1, values: { title: 'before history' } },
})
manager.handleControlFrame({
type: 'baseline', value: { jobs: { [S1]: [] }, projections: { [S1]: { asOfSeq: -1, values: { title: 'cold baseline' } } } },
})
expect(manager.getListSnapshot().items[0]?.title).toBe('before history')
expect(manager.getListSnapshot().jobsBySession).toEqual({})
} finally {
await manager.dispose()
}
})
it('single-flights refreshList and preserves the Host baseline order', async ({ mock, remote }) => {
const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
remote.session.list.mockReturnValue(gate.promise)
@@ -262,7 +360,7 @@ describe('Host Remote event routing', () => {
})
describe('subagent catalogs', () => {
it('keeps a catalog-discovered child address across ordinary selection and status frames', async ({ mock, remote, start }) => {
it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => {
remote.session.list.mockResolvedValue(ok({ items: [
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
@@ -279,9 +377,9 @@ describe('subagent catalogs', () => {
const manager = new SessionManager(client.ctx.remote)
await manager.refreshList()
await manager.refreshSubagents(S1)
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
manager.resolveTarget({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
expect(manager.getListSnapshot().currentAddress).toEqual({
expect(manager.subagentAddress(S2)).toEqual({
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
@@ -290,8 +388,8 @@ describe('subagent catalogs', () => {
})
// Clicking the same child through an ordinary list-selection path must not
// erase the catalog-derived address and fall back to session.* transport.
manager.select(S2)
expect(manager.getListSnapshot().currentAddress).toEqual({
manager.resolveTarget(S2)
expect(manager.subagentAddress(S2)).toEqual({
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
@@ -497,7 +595,7 @@ describe('subagent catalogs', () => {
const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
const second = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockReturnValue(first.promise)
const manager = makeManager(mock, remote, root)
const manager = makeManager(mock, remote)
const refresh = manager.refreshSubagents(root)
manager.setSubagentCatalogOpen(root, true)
@@ -556,7 +654,7 @@ describe('subagent catalogs', () => {
const refresh = manager.refreshSubagents(root)
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await refresh
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
manager.resolveTarget({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
// The removal lands while a second pull is in flight: the invalidation
// must survive the pre-removal ok response, so one trailing pull runs.
@@ -596,7 +694,7 @@ describe('subagent catalogs', () => {
}))
const manager = makeManager(mock, remote)
await manager.refreshSubagents(root)
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
manager.resolveTarget({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
manager.handleSessionRemoved(root)
@@ -712,34 +810,29 @@ describe('remaining branches', () => {
expect(manager.getListSnapshot().items).toBe(after.items)
})
it('reuses refreshed rows and evicts missing rows while retaining the selection candidate', async ({ mock, remote }) => {
const manager = makeManager(mock, remote, S2)
it('reuses refreshed rows and evicts missing rows independently of Client instances', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
await manager.refreshList()
const first = manager.getListSnapshot()
expect(first.current).toBe(S2)
remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
await manager.refreshList()
expect(manager.getListSnapshot().items).toBe(first.items)
expect(manager.getListSnapshot().current).toBe(S2)
remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
await manager.refreshList()
expect(manager.getListSnapshot().items).toEqual([first.items[0]])
expect(manager.getListSnapshot().items[0]).toBe(first.items[0])
expect(manager.getListSnapshot().current).toBeUndefined()
remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
await manager.refreshList()
expect(manager.getListSnapshot().items[0]).toBe(first.items[0])
expect(manager.getListSnapshot().items[1]).not.toBe(first.items[1])
expect(manager.getListSnapshot().current).toBe(S2)
remote.session.list.mockResolvedValue(ok({ items: [] as never[] }))
await manager.refreshList()
expect(manager.getListSnapshot().items).toEqual([])
expect(manager.getListSnapshot().current).toBeUndefined()
remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
await manager.refreshList()
@@ -749,7 +842,7 @@ describe('remaining branches', () => {
it('bounds cached-row ID reads linearly during repeated list refreshes', async ({ mock, remote }) => {
const count = 1_000
const summaries = Array.from({ length: count }, (_, i) => summary(`list-${i}` as SessionId))
const manager = makeManager(mock, remote, summaries[count - 1]!.sessionId)
const manager = makeManager(mock, remote)
remote.session.list.mockResolvedValue(ok({ items: summaries as never[] }))
await manager.refreshList()
const first = manager.getListSnapshot()
@@ -765,7 +858,6 @@ describe('remaining branches', () => {
await manager.refreshList()
const snapshot = manager.getListSnapshot()
expect(snapshot.items).toBe(first.items)
expect(snapshot.current).toBe(summaries[count - 1]!.sessionId)
expect(reads).toBeLessThanOrEqual(count * 3)
}
})
@@ -872,17 +964,25 @@ describe('connected generation', () => {
expect(remote.session.page).toHaveBeenCalledTimes(historyCallsBefore)
})
it('retains the durable parent address and refreshes its catalogs across reconnect', async ({ mock, remote }) => {
it('retains the durable parent address and refreshes that parent across reconnect', async ({ mock, remote }) => {
const address = {
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
}
const parent = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
const child = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockImplementation(payload => (payload === S1 ? parent.promise : child.promise))
const manager = makeManager(mock, remote, S2, address)
const manager = makeManager(mock, remote)
remote.subagents.list.mockResolvedValueOnce(ok({
entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'inactive', hasChildren: false }],
parentAvailable: true,
}))
await manager.refreshSubagents(S1)
manager.resolveTarget(address)
manager.get(S2)
remote.subagents.list.mockClear()
manager.handleConnected()
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address, parentAvailable: true })
parent.resolve(ok({ entries: [], parentAvailable: true }))
child.resolve(ok({ entries: [], parentAvailable: true }))
@@ -890,132 +990,30 @@ describe('connected generation', () => {
expect(remote.session.list).toHaveBeenCalledOnce()
})
await vi.waitFor(() => {
expect(remote.subagents.list.mock.calls.map(([parentSessionId]) => parentSessionId)).toEqual([S1, S2])
expect(remote.subagents.list.mock.calls.map(([parentSessionId]) => parentSessionId)).toEqual([S1])
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address,
parentAvailable: true,
})
expect(manager.getListSnapshot().currentAddress).toEqual(address)
expect(manager.subagentAddress(S2)).toEqual(address)
})
})
describe('completed reminder', () => {
const status = (manager: SessionManager, sessionId: SessionId, running: boolean): void => {
manager.handleSessionStatus(sessionId, running)
}
const added = (manager: SessionManager, sessionId: SessionId): void => {
manager.handleSessionAdded(summary(sessionId))
}
const entry = (manager: SessionManager, sessionId: SessionId) =>
manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
it('arms on a running→idle flip of a non-selected session and clears on select', ({ mock, remote }) => {
describe('running facts without UI reminders', () => {
it('replays running status during hydration without publishing a completion marker', async ({ mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
remote.session.list.mockReturnValueOnce(response.promise)
const manager = makeManager(mock, remote)
added(manager, S1)
added(manager, S2)
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
// Opening the session consumes the reminder.
manager.select(S2)
expect(entry(manager, S2)?.completed).toBe(false)
})
it('never arms for the session being watched and re-arms after a switch-away re-run', ({ mock, remote }) => {
const manager = makeManager(mock, remote)
added(manager, S1)
added(manager, S2)
manager.select(S2)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder
// Switch away; a fresh run completing again arms the reminder.
manager.select(S1)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
})
it('a re-run disarms the reminder while running and re-arms on its completion', ({ mock, remote }) => {
const manager = makeManager(mock, remote)
added(manager, S1)
added(manager, S2)
manager.select(S1)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
// The user starts a new run without opening the session: running wins.
status(manager, S2, true)
expect(entry(manager, S2)?.completed).toBe(false)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
})
it('session-removed drops the reminder and a re-add starts clean', ({ mock, remote }) => {
const manager = makeManager(mock, remote)
added(manager, S1)
added(manager, S2)
manager.select(S1)
status(manager, S2, true)
status(manager, S2, false)
expect(entry(manager, S2)?.completed).toBe(true)
manager.handleSessionRemoved(S2)
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined()
added(manager, S2)
expect(entry(manager, S2)?.completed).toBe(false)
})
it('a list refresh carrying the running→idle transition arms the reminder', async ({ mock, remote }) => {
remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
const manager = makeManager(mock, remote)
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(true)
})
it('never arms for sessions already idle at first observation', async ({ mock, remote }) => {
remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
const manager = makeManager(mock, remote)
await manager.refreshList()
manager.select(S1)
expect(entry(manager, S2)?.completed).toBe(false)
remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
await manager.refreshList()
expect(entry(manager, S2)?.completed).toBe(false)
})
it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async ({ mock, remote }) => {
const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
remote.session.list.mockReturnValue(gate.promise)
const manager = makeManager(mock, remote)
const refresh = manager.refreshList()
// The session finishes while the first pull is still in flight; the pull
// response recorded it as running at pull time.
status(manager, S2, false)
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
})
it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async ({ mock, remote }) => {
const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
remote.session.list.mockReturnValue(gate.promise)
const manager = makeManager(mock, remote)
const refresh = manager.refreshList()
// The unknown session starts and finishes while the first pull is in
// flight; the pull-time baseline recorded it idle, so the running→idle
// edge lives entirely inside the replayed mutations.
status(manager, S2, true)
status(manager, S2, false)
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
await refresh
expect(entry(manager, S2)?.completed).toBe(true)
const refreshing = manager.refreshList()
manager.handleSessionStatus(S1, true)
manager.handleSessionStatus(S1, false)
response.resolve(ok({ items: [summary(S1)] }))
await refreshing
const entry = manager.getListSnapshot().items.find(item => item.sessionId === S1)
expect(entry).toMatchObject({ running: false })
expect(entry).not.toHaveProperty('completed')
expect(manager.getListSnapshot()).not.toHaveProperty('current')
})
})
@@ -0,0 +1,324 @@
/** Source-labelled Client references over real history transport and scoped Contexts. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, onTestFinished, vi } from 'vitest'
import type {
SessionReference, SessionReferenceSource, SessionRetainInfo,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
import { createClientTest, webApp, type TestClient } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import { ClientSessions } from '../src/client/sessions/service.ts'
import { FOLLOW, followScript, type HistoryAnswer } from './remote/session.client.ts'
declare module '@deepseek-ai/dsh-api-session-controller/client' {
interface SessionReferenceSourceMap {
referenceTestView: unknown
referenceTestWork: unknown
}
}
const viewSource: SessionReferenceSource = 'referenceTestView'
const workSource: SessionReferenceSource = 'referenceTestWork'
const ID = SessionId('reference-session')
const EMPTY_HISTORY = ok({ records: [], hasMore: false })
const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) })
async function bench(mock: RemoteMock, start: () => Promise<TestClient>, listed = true) {
const client = await start()
const ctx = new Context()
const svc = new ClientSessions(ctx, client.ctx.remote)
const unblock: Array<() => void> = []
onTestFinished(async () => {
for (const finish of unblock) finish()
await ctx.fiber.dispose()
})
mock.stream(FOLLOW, followScript(EMPTY_HISTORY))
const feed = async (include: boolean): Promise<void> => {
mock.remote.session.list.mockResolvedValue(ok({ items: include
? [{ sessionId: ID, updatedAt: 1, running: false, blank: true }]
: [] }))
await svc.refresh()
}
if (listed) await feed(true)
return { svc, ctx, mock, feed, unblock }
}
describe('Client reference sources', () => {
it('observes unknown identities without creating a scope, reference, or history request', async ({ mock, start }) => {
const b = await bench(mock, start, false)
const source = b.svc.retainInfo(ID)
const before = source.getSnapshot()
const stop = source.subscribe(vi.fn())
expect(b.svc.retainInfo(ID)).toBe(source)
expect(source.getSnapshot()).toBe(before)
expect(before).toEqual({ referenceCount: 0, retainedBy: {} })
expect('set' in source).toBe(false)
expect(b.svc.scope(ID)).toBeUndefined()
expect(b.svc.binding(ID)).toBeUndefined()
expect(mock.log.requests(FOLLOW)).toHaveLength(0)
expect(() => b.svc.retain(ID, { source: viewSource })).toThrow('unknown session')
expect(source.getSnapshot()).toBe(before)
stop()
})
it('shares initial opening while exposing independent source contributions before the await', async ({ mock, start }) => {
const b = await bench(mock, start)
const opening = Promise.withResolvers<Awaited<HistoryAnswer>>()
const entered = Promise.withResolvers<undefined>()
b.unblock.push(() => { opening.resolve(EMPTY_HISTORY) })
mock.stream(FOLLOW, followScript(() => { entered.resolve(undefined); return opening.promise }))
const source = b.svc.retainInfo(ID)
const first = b.svc.retain(ID, { source: viewSource })
const binding = b.svc.binding(ID)
const second = b.svc.retain(ID, { source: workSource })
const acquisitions = Promise.allSettled([first.ready, second.ready])
expect(binding).toBeDefined()
expect(source.getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { referenceTestView: 1, referenceTestWork: 1 } })
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(source.getSnapshot().retainedBy)
await entered.promise
expect(mock.log.requests(FOLLOW)).toHaveLength(1)
opening.resolve(EMPTY_HISTORY)
await acquisitions
using a = first
using c = second
expect(a.binding).toBe(binding)
expect(c.binding).toBe(binding)
expect(a.binding.session.getSnapshot().openState).toBe('open')
a.release()
a[Symbol.dispose]()
expect(source.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { referenceTestWork: 1 } })
expect(() => a.binding).toThrow('is released')
c.release()
expect(source.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
expect(b.svc.binding(ID)).toBeUndefined()
})
it('counts repeated uses of one source and omits it after the final release', async ({ mock, start }) => {
const b = await bench(mock, start)
using first = b.svc.retain(ID, { source: workSource, signal: undefined })
using second = b.svc.retain(ID, { source: workSource })
await Promise.all([first.ready, second.ready])
const source = b.svc.retainInfo(ID)
expect(source.getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { referenceTestWork: 2 } })
first.release()
expect(source.getSnapshot().retainedBy.referenceTestWork).toBe(1)
second.release()
expect(source.getSnapshot().retainedBy.referenceTestWork).toBeUndefined()
expect(Object.keys(source.getSnapshot().retainedBy)).toEqual([])
})
it('keeps counts through catalog refresh/removal and projects them when the row returns', async ({ mock, start }) => {
const b = await bench(mock, start)
using reference = b.svc.retain(ID, { source: viewSource })
await reference.ready
const binding = reference.binding
const source = b.svc.retainInfo(ID)
const snapshot = source.getSnapshot()
await b.feed(true)
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy)
b.svc.handleSessionRemoved(ID)
await vi.waitFor(() => {
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy)
})
expect(b.svc.list.getSnapshot().ids).not.toContain(ID)
expect(source.getSnapshot()).toBe(snapshot)
expect(b.svc.binding(ID)).toBe(binding)
await b.feed(true)
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy)
reference.release()
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({})
})
it('retains a synchronous Gateway Context before catalog discovery without history I/O', async ({ mock, start }) => {
const b = await bench(mock, start, false)
const source = b.svc.retainInfo(ID)
using reference = b.svc.retainAgentScope(ID)
expect(reference.binding.ctx).toBe(b.svc.scope(ID))
expect(reference.binding.session.getSnapshot().openState).toBe('cold')
expect(source.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { gateway: 1 } })
expect(b.svc.list.getSnapshot().ids).not.toContain(ID)
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({ gateway: 1 })
expect(mock.log.requests(FOLLOW)).toHaveLength(0)
expect(mock.remote.subagents.list).not.toHaveBeenCalled()
await b.feed(true)
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({ gateway: 1 })
})
for (const kind of ['remote', 'unexpected'] as const) it(`settles ${kind} initial opening failure through Session state and allows another acquisition after release`, async ({ mock, start }) => {
const b = await bench(mock, start)
using local = b.svc.retainAgentScope(ID)
const binding = local.binding
const failure = kind === 'remote'
? new RemoteError('session/not-found', 'opening failed', { sessionId: ID })
: new Error('opening failed')
mock.stream(FOLLOW, followScript(() => Promise.reject(failure)))
const failed = b.svc.retain(ID, { source: viewSource })
await expect(failed.ready).resolves.toBe(binding)
expect(b.svc.retainInfo(ID).getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { gateway: 1, referenceTestView: 1 } })
expect(binding.session.getSnapshot().openState).toBe('error')
failed.release()
mock.stream(FOLLOW, followScript(EMPTY_HISTORY))
using retried = b.svc.retain(ID, { source: workSource })
await retried.ready
expect(retried.binding).toBe(binding)
expect(retried.binding.session.getSnapshot().openState).toBe('open')
})
it('cancels only one waiter while another owns the shared initial opening', async ({ mock, start }) => {
const b = await bench(mock, start)
const opening = Promise.withResolvers<Awaited<HistoryAnswer>>()
b.unblock.push(() => { opening.resolve(EMPTY_HISTORY) })
mock.stream(FOLLOW, followScript(opening.promise))
const controller = new AbortController()
const cancelled = b.svc.retain(ID, { source: viewSource, signal: controller.signal })
const survivor = b.svc.retain(ID, { source: workSource })
const reason = new Error('waiter cancelled')
const rejected = expect(cancelled.ready).rejects.toBe(reason)
controller.abort(reason)
await rejected
expect(b.svc.retainInfo(ID).getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { referenceTestView: 1, referenceTestWork: 1 } })
cancelled.release()
expect(b.svc.retainInfo(ID).getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { referenceTestWork: 1 } })
opening.resolve(EMPTY_HISTORY)
using reference = survivor
await reference.ready
expect(reference.binding.session.getSnapshot().openState).toBe('open')
expect(mock.log.requests(FOLLOW)).toHaveLength(1)
})
it('rejects a previously cancelled acquisition without creating a generation or publishing counts', async ({ mock, start }) => {
const b = await bench(mock, start)
const controller = new AbortController()
const reason = new Error('acquisition cancelled')
controller.abort(reason)
const changed = vi.fn()
const source = b.svc.retainInfo(ID)
b.ctx.effect(() => source.subscribe(changed), 'test: reference observation')
const before = source.getSnapshot()
expect(() => b.svc.retain(ID, { source: viewSource, signal: controller.signal })).toThrow(reason)
expect(source.getSnapshot()).toBe(before)
expect(changed).not.toHaveBeenCalled()
expect(b.svc.binding(ID)).toBeUndefined()
expect(mock.log.requests(FOLLOW)).toHaveLength(0)
})
it('releases a synchronously cancelled readiness wait through using()', async ({ mock, start }) => {
const b = await bench(mock, start)
const controller = new AbortController()
const failure = new Error('owner ended during acquisition')
const source = b.svc.retainInfo(ID)
b.ctx.effect(() => source.subscribe(() => {
if (source.getSnapshot().referenceCount > 0) controller.abort(failure)
}), 'test: acquisition cancellation')
await expect(b.svc.using(ID, { source: viewSource, signal: controller.signal }, () => undefined)).rejects.toBe(failure)
expect(source.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
expect(b.svc.binding(ID)).toBeUndefined()
})
it('withdraws the old generation before an observer retains a same-id replacement', async ({ mock, start }) => {
const b = await bench(mock, start)
using old = b.svc.retain(ID, { source: viewSource })
await old.ready
const binding = old.binding
const source = b.svc.retainInfo(ID)
const replacement = Promise.withResolvers<SessionReference>()
const stop = source.subscribe(() => {
if (source.getSnapshot().referenceCount !== 0) return
stop()
replacement.resolve(b.svc.retainAgentScope(ID))
})
b.ctx.effect(() => stop, 'test: generation replacement')
old.release()
using reference = await replacement.promise
await binding.ctx.fiber.dispose()
old.release()
expect(reference.binding).not.toBe(binding)
expect(reference.binding.session.getSnapshot().openState).toBe('cold')
expect(b.svc.sessionOf(binding.ctx)).toBeUndefined()
expect(source.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { gateway: 1 } })
expect(mock.log.requests(FOLLOW)).toHaveLength(1)
})
it('keeps one observable across replacement while late old cleanup cannot change its counts', async ({ mock, start }) => {
const b = await bench(mock, start)
const source = b.svc.retainInfo(ID)
const old = b.svc.retain(ID, { source: viewSource })
await old.ready
const binding = old.binding
const entered = Promise.withResolvers<undefined>()
const resume = Promise.withResolvers<undefined>()
b.unblock.push(() => { resume.resolve(undefined) })
binding.ctx.effect(() => async () => { entered.resolve(undefined); await resume.promise }, 'test: delayed generation cleanup')
old.release()
expect(b.svc.binding(ID)).toBeUndefined()
expect(source.getSnapshot().referenceCount).toBe(0)
await entered.promise
using replacement = b.svc.retain(ID, { source: workSource })
await replacement.ready
expect(replacement.binding).not.toBe(binding)
const snapshot = source.getSnapshot()
resume.resolve(undefined)
await binding.ctx.fiber.dispose()
old.release()
expect(b.svc.retainInfo(ID)).toBe(source)
expect(source.getSnapshot()).toBe(snapshot)
expect(b.svc.sessionOf(binding.ctx)).toBeUndefined()
expect(b.svc.binding(ID)).toBe(replacement.binding)
})
it('invalidates references and zeroes their observable on root disposal', async ({ mock, start }) => {
const b = await bench(mock, start)
const reference = b.svc.retain(ID, { source: viewSource })
await reference.ready
const source = b.svc.retainInfo(ID)
await b.ctx.fiber.dispose()
expect(source.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
expect(() => reference.binding).toThrow('is released')
reference.release()
expect(() => b.svc.retain(ID, { source: workSource })).toThrow('Controller is disposed')
expect(() => b.svc.retainAgentScope(ID)).toThrow('Controller is disposed')
})
})
describe('ClientSessions.using', () => {
it('awaits callback settlement before releasing and returns its result', async ({ mock, start }) => {
const b = await bench(mock, start)
const entered = Promise.withResolvers<SessionReference>()
const response = Promise.withResolvers<number>()
b.unblock.push(() => { response.resolve(7) })
const using = b.svc.using(ID, { source: workSource, signal: undefined }, (reference) => {
entered.resolve(reference)
return response.promise
})
const reference = await entered.promise
expect(b.svc.retainInfo(ID).getSnapshot().referenceCount).toBe(1)
response.resolve(7)
await expect(using).resolves.toBe(7)
expect(b.svc.retainInfo(ID).getSnapshot().referenceCount).toBe(0)
expect(() => reference.binding).toThrow('is released')
})
for (const kind of ['sync', 'async'] as const) it(`releases and propagates a ${kind} callback failure`, async ({ mock, start }) => {
const b = await bench(mock, start)
const failure = new Error('callback failed')
await expect(b.svc.using(ID, { source: workSource }, () => {
if (kind === 'sync') throw failure
return Promise.reject(failure)
})).rejects.toBe(failure)
expect(b.svc.retainInfo(ID).getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
})
it('calls the operation after a stateful opening failure', async ({ mock, start }) => {
const b = await bench(mock, start)
const operation = vi.fn(() => 7)
mock.stream(FOLLOW, followScript(() => Promise.reject(new Error('cannot open'))))
await expect(b.svc.using(ID, { source: workSource }, operation)).resolves.toBe(7)
expect(operation).toHaveBeenCalledOnce()
const info: SessionRetainInfo = b.svc.retainInfo(ID).getSnapshot()
expect(info).toEqual({ referenceCount: 0, retainedBy: {} })
expect(b.svc.binding(ID)).toBeUndefined()
})
})
@@ -39,6 +39,23 @@ function bench() {
}
describe('createScope', () => {
it('routes same-id generations independently while keeping untagged listeners shared', async () => {
const root = new Context()
const previous = createScope(root, sid('same'))
const replacement = createScope(root, sid('same'))
const seen: string[] = []
previous.ctx.on('test/scope-probe', () => { seen.push('old'); return undefined })
replacement.ctx.on('test/scope-probe', () => { seen.push('new'); return undefined })
root.on('test/scope-probe', () => { seen.push('root'); return undefined })
replacement.ctx.emit(replacement.ctx, 'test/scope-probe', { from: 'new' })
expect(seen).toEqual(['new', 'root'])
await previous.fiber.dispose()
seen.length = 0
replacement.ctx.emit(replacement.ctx, 'test/scope-probe', { from: 'new' })
expect(seen).toEqual(['new', 'root'])
await root.fiber.dispose()
})
it('tags the ctx (scopeOf) and leaves the root untagged', () => {
const { root, a } = bench()
expect(scopeOf(a.ctx)).toBe(sid('a'))
@@ -5,14 +5,15 @@
* proxies and is answered by endpoint name.
*/
import { describe, expect, vi } from 'vitest'
import { describe, expect, onTestFinished, vi } from 'vitest'
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import { JUMP_PAGE_MESSAGES, type Session } from '../src/client/sessions/session.ts'
import { JUMP_PAGE_MESSAGES, Session } from '../src/client/sessions/session.ts'
import { SessionEventStream } from '../src/client/transport.ts'
import type { SessionFollowRequest, SessionPage, SessionPageRequest } from '../src/types.ts'
import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
@@ -40,6 +41,15 @@ function eventSeqs(session: Session): number[] {
}
describe('Session open', () => {
it('rejects a second scope binding until the owned scope is unbound', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
const client = await start()
session.bindScope(client.ctx)
expect(() => { session.bindScope(client.ctx.extend()) }).toThrow('already has a bound scope')
session.unbindScope()
expect(() => { session.bindScope(client.ctx.extend()) }).not.toThrow()
})
it('keeps a bare Session blank until an authoritative lifecycle signal arrives', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, running: false })
@@ -560,6 +570,31 @@ describe('prompt and cancel errors', () => {
sessionId: SID, attachmentId: 'attachment-1',
}])
})
it('forwards attachment rejection without trying to decode absent bytes', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
const failure = new RemoteError('gateway/internal', 'attachment unavailable', {})
mock.remote.session.attachment.mockResolvedValueOnce(err(failure))
await expect(session.readAttachment(AttachmentId('missing'))).resolves.toMatchObject({ ok: false, error: failure })
})
it('reports an unmatched command and forwards execution failures without opening history', async ({ mock, start }) => {
const client = await start()
// The Gateway-only assembly has no commands contribution; this facade test supplies its unary dependency.
const session = new Session(SID, {
session: client.ctx.remote.session,
subagents: client.ctx.remote.subagents,
commands: mock.remote.commands,
$stream: client.ctx.remote.$stream.bind(client.ctx.remote),
})
onTestFinished(() => session.dispose())
mock.remote.commands.execute.mockResolvedValueOnce(ok(undefined))
await expect(session.command('/unknown')).resolves.toEqual({ ok: true, value: { matched: false } })
const failure = new RemoteError('gateway/internal', 'command unavailable', {})
mock.remote.commands.execute.mockResolvedValueOnce(err(failure))
await expect(session.command('/failed')).resolves.toMatchObject({ ok: false, error: failure })
expect(mock.log.requests(FOLLOW)).toHaveLength(0)
})
})
describe('rename', () => {
@@ -661,7 +696,7 @@ describe('remaining branches', () => {
expect(notified).toBe(seen)
})
it('rejects an opening page that does not end at the opening cursor', async ({ mock, start }) => {
it('records an opening page that does not end at the opening cursor', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')), { cursor: 11 }))
await session.open()
@@ -722,7 +757,7 @@ describe('remaining branches', () => {
expect(eventSeqs(session)).toHaveLength(6)
})
it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async ({ mock, start }) => {
it('drops a stale opening without replacing the new generation error state', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
const stale = Promise.withResolvers<RemoteResult<SessionPage>>()
mock.stream(FOLLOW, followScript(() => stale.promise))
@@ -770,11 +805,6 @@ describe('remaining branches', () => {
expect(session.getSnapshot().promptError).toBeNull()
})
it('dispose is a reserved no-op on resident instances', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
await expect(session.dispose()).resolves.toBeUndefined()
})
it('carries raw history and follow events through the event feed', async ({ mock, start }) => {
const session = await sessionBench(mock, start, SID)
const historyCall = ev.toolCall(SessionSeq(6), 1, 'h1', 'bash', '{"cmd":"pwd"}')
@@ -1,21 +1,15 @@
/**
* ClientSessions: list store projection (manager → {ids, byId, current}
* with derived titles), the current-selection account (open validation and
* persisted mask semantics), scope-tree
* lifecycle (lazy mint / frozen survival / removed teardown with staged
* deferral — the stage follows list.current), binding identity, breadcrumb
* projection, create.
*/
/** Client catalog projection, explicitly retained scopes, streams, and Host operations. */
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, vi } from 'vitest'
import { describe, expect, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionReference } from '../src/client/contract/sessions.ts'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types'
import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts'
import { ClientSessions, SessionCreateError, SessionForkError } from '../src/client/sessions/service.ts'
import { scopeOf } from '../src/client/scope.ts'
import type {
SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest,
@@ -32,6 +26,7 @@ interface Bench {
ctx: Context
mock: RemoteMock
svc: ClientSessions
unblock: Array<() => void>
}
type BenchFactory = () => Bench
@@ -40,16 +35,18 @@ const it = createClientTest({ roster: API_ROSTER }).extend<{ bench: BenchFactory
bench: async ({ mock, start }, use) => {
mock.load(sessionWorld)
const client = await start()
const contexts: Context[] = []
const benches: Bench[] = []
try {
await use(() => {
const ctx = new Context()
contexts.push(ctx)
const svc = new ClientSessions(ctx, client.ctx.remote)
return { ctx, mock, svc }
const b = { ctx, mock, svc, unblock: [] as Array<() => void> }
benches.push(b)
return b
})
} finally {
await Promise.all(contexts.map(ctx => ctx.fiber.dispose()))
for (const b of benches) for (const finish of b.unblock) finish()
await Promise.all(benches.map(b => b.ctx.fiber.dispose()))
}
},
})
@@ -152,7 +149,8 @@ describe('scope tree', () => {
it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await _reference.ready
const binding = b.svc.binding(sid('s1'))
if (binding === undefined) throw new Error('expected Session binding')
await vi.waitFor(() => {
@@ -243,7 +241,8 @@ describe('scope tree', () => {
{ assistantStream: () => assistantStreamBaseline },
))
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await _reference.ready
const binding = b.svc.binding(sid('s1'))
if (binding === undefined) throw new Error('expected Session binding')
await vi.waitFor(() => {
@@ -328,7 +327,8 @@ describe('scope tree', () => {
}
b.mock.stream(FOLLOW, followScript(history, { assistantStream: assistantStreamBaseline }))
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await _reference.ready
const binding = b.svc.binding(sid('s1'))
if (binding === undefined) throw new Error('expected Session binding')
await vi.waitFor(() => {
@@ -404,7 +404,8 @@ describe('scope tree', () => {
{ assistantStream: () => assistantStreamBaseline },
))
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await _reference.ready
const binding = b.svc.binding(sid('s1'))
if (binding === undefined) throw new Error('expected Session binding')
await vi.waitFor(() => {
@@ -437,72 +438,33 @@ describe('scope tree', () => {
})
})
it('retains a Host-addressed scope until the first Session baseline owns pruning', async ({ bench }) => {
it('holds a Host-addressed Context across an empty catalog baseline', async ({ bench }) => {
const b = bench()
const scoped = b.svc.resolveAgentScope(sid('s-early'))
using reference = b.svc.retainAgentScope(sid('s-early'))
const scoped = reference.binding.ctx
expect(scopeOf(scoped)).toBe('s-early')
b.svc.handleControlFrame({
type: 'baseline',
value: { jobs: {}, projections: {} },
})
await Promise.resolve()
expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped)
b.svc.handleControlFrame({ type: 'baseline', value: { jobs: {}, projections: {} } })
await feedList(b, [])
expect(b.svc.scope(sid('s-early'))).toBe(scoped)
reference.release()
expect(b.svc.scope(sid('s-early'))).toBeUndefined()
})
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async ({ bench }) => {
it('borrows only retained bindings and preserves them while the catalog changes', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
expect(b.svc.scope(sid('unknown'))).toBeUndefined()
const scoped = b.svc.scope(sid('s1'))
expect(scoped).toBeDefined()
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
b.svc.open(sid('s1'))
expect(b.svc.sessionOf(scoped as Context)).toBe(binding?.session)
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
it('tears down an off-stage removed session but defers the staged one until the stage moves', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const ctx1 = b.svc.scope(sid('s1'))
b.svc.open(sid('s1')) // s1 staged (current)
b.svc.scope(sid('s2')) // s2 scoped but off stage
await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
expect(b.svc.scope(sid('s2'))).toBeUndefined()
await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBe(ctx1)
await feedList(b, [{ id: 's3' }])
b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
expect(b.svc.scope(sid('s1'))).toBeUndefined()
})
it('keeps the scope when the session merely stops running (frozen ≠ removed)', async ({ bench }) => {
const b = bench()
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await reference.ready
const binding = reference.binding
expect(b.svc.scope(sid('s1'))).toBe(binding.ctx)
expect(b.svc.sessionOf(binding.ctx)).toBe(binding.session)
await feedList(b, [])
expect(b.svc.binding(sid('s1'))).toBe(binding)
await feedList(b, [{ id: 's1', running: true }])
const scoped = b.svc.scope(sid('s1'))
await feedList(b, [{ id: 's1', running: false }])
expect(b.svc.scope(sid('s1'))).toBe(scoped)
})
it('cancels a deferred teardown when the id reappears in the list', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const scoped = b.svc.scope(sid('s1'))
b.svc.open(sid('s1'))
await feedList(b, []) // removed while staged → deferred
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
expect(b.svc.scope(sid('s1'))).toBe(scoped)
expect(b.svc.binding(sid('s1'))).toBe(binding)
reference.release()
expect(b.svc.binding(sid('s1'))).toBeUndefined()
})
it('closes an opened journal when its removed scope drops', async ({ bench }) => {
@@ -512,7 +474,8 @@ describe('scope tree', () => {
return request.address.kind === 'session' && request.address.sessionId === sid('s1')
})
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await reference.ready
const session = b.svc.binding(sid('s1'))?.session
if (session === undefined) throw new Error('expected the selected Session binding')
await vi.waitFor(() => { expect(follows().filter(stream => stream.state === 'open')).toHaveLength(1) })
@@ -520,8 +483,8 @@ describe('scope tree', () => {
session.subscribe(notified)
await feedList(b, [])
await feedList(b, [{ id: 's2' }])
b.svc.open(sid('s2'))
expect(b.svc.binding(sid('s1'))?.session).toBe(session)
reference.release()
await vi.waitFor(() => { expect(follows().filter(stream => stream.state === 'open')).toHaveLength(0) })
notified.mockClear()
@@ -548,7 +511,8 @@ describe('Agent scope disposal lifecycle', () => {
sessionId: sid('live'), updatedAt: 1, running: false, blank: true,
})
await Promise.resolve()
const scoped = b.svc.scope(sid('live'))
using reference = b.svc.retainAgentScope(sid('live'))
const scoped = reference.binding.ctx
if (scoped === undefined) throw new Error('fixture Agent Context was not minted')
await scoped.fiber.await()
const scopeDisposed = vi.fn()
@@ -564,6 +528,7 @@ describe('Agent scope disposal lifecycle', () => {
const abortObserved = vi.fn()
let followSignal: AbortSignal | undefined
const b = bench()
b.unblock.push(() => { closeGate.resolve(undefined) })
b.mock.remote.session.follow.mockImplementation((request, signal) => {
if (signal === undefined) throw new Error('fixture requires a signal')
followSignal = signal
@@ -610,7 +575,8 @@ describe('Agent scope disposal lifecycle', () => {
const readiness = b.ctx.plugin(() => undefined)
await readiness
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await _reference.ready
await vi.waitFor(() => {
expect(b.svc.binding(sid('s1'))?.session.getSnapshot().openState).toBe('open')
})
@@ -628,10 +594,11 @@ describe('Agent scope disposal lifecycle', () => {
expect(settled).toHaveBeenCalledOnce()
})
it('root disposal joins every Session drop already started by pruning under load', async ({ bench }) => {
it('root disposal joins every Session drop already started by final release under load', async ({ bench }) => {
const closeGates = new Map<SessionId, PromiseWithResolvers<undefined>>()
const aborted = new Set<SessionId>()
const b = bench()
b.unblock.push(() => { for (const gate of closeGates.values()) gate.resolve(undefined) })
b.mock.remote.session.follow.mockImplementation((request, signal) => {
if (signal === undefined) throw new Error('fixture requires a signal')
const sessionId = request.address.kind === 'session'
@@ -679,7 +646,12 @@ describe('Agent scope disposal lifecycle', () => {
const held = sessionIds[0]
if (retained === undefined || held === undefined) throw new Error('fixture requires sessions')
await feedList(b, sessionIds.map(id => ({ id })))
for (const id of sessionIds) b.svc.open(id)
const references = new Map<SessionId, SessionReference>()
for (const id of sessionIds) {
const reference = b.svc.retain(id, { source: 'controllerOperation' })
await reference.ready
references.set(id, reference)
}
await vi.waitFor(() => {
for (const id of sessionIds) {
expect(b.svc.binding(id)?.session.getSnapshot().openState).toBe('open')
@@ -688,6 +660,7 @@ describe('Agent scope disposal lifecycle', () => {
const pruned = sessionIds.slice(0, -1)
await feedList(b, [{ id: retained }])
for (const id of pruned) references.get(id)?.release()
await vi.waitFor(() => { expect(aborted.size).toBe(pruned.length) })
for (const id of pruned) expect(b.svc.scope(id)).toBeUndefined()
@@ -703,7 +676,6 @@ describe('Agent scope disposal lifecycle', () => {
otherClosures.push(gate.promise)
}
await Promise.all(otherClosures)
await new Promise((resolve) => { setTimeout(resolve, 0) })
expect(settled).not.toHaveBeenCalled()
closeGates.get(held)?.resolve(undefined)
@@ -712,126 +684,42 @@ describe('Agent scope disposal lifecycle', () => {
})
})
describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
afterEach(() => { vi.unstubAllGlobals() })
it('open() writes list.current; unknown ids fail loud', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
expect(b.svc.list.getSnapshot().current).toBeUndefined()
b.svc.open(sid('s1'))
expect(b.svc.list.getSnapshot().current).toBe('s1')
expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
})
it('clear() blanks list.current and the persisted selection', async ({ bench }) => {
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
removeItem: (k: string) => { storage.delete(k) },
clear: () => { storage.clear() },
})
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
expect(storage.get('dsh.sessions.current')).toContain('s1')
b.svc.clear()
expect(b.svc.list.getSnapshot().current).toBeUndefined()
// Persisted wipe: a fresh service with the same storage stays on empty.
const again = bench()
await feedList(again, [{ id: 's1' }])
expect(again.svc.list.getSnapshot().current).toBeUndefined()
})
it('masks (not destroys) the selection while its session is off the list', async ({ bench }) => {
describe('borrow-only bindings', () => {
it('keeps catalog discovery separate from history opening and ownership', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1'))
await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
expect(b.svc.list.getSnapshot().current).toBeUndefined()
await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async ({ bench }) => {
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
const first = bench()
await feedList(first, [{ id: 's1' }])
first.svc.open(sid('s1'))
expect(storage.get('dsh.sessions.current')).toContain('s1')
// A fresh boot (same storage) recovers the selection once the list holds the session.
const second = bench()
await feedList(second, [{ id: 's1' }])
expect(second.svc.list.getSnapshot().current).toBe('s1')
})
})
describe('binding and stage lifecycle', () => {
it('binding() is pure resolution: no staging, no deferred sweep', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged
b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const followStarts = () => b.mock.log.requests(FOLLOW).map((request) => {
const address = (request as SessionFollowRequest).address
return String(address.kind === 'session' ? address.sessionId : address.childSessionId)
})
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
b.svc.binding(sid('s1'))
expect(followStarts()).toEqual([])
b.svc.open(sid('s1'))
await vi.waitFor(() => {
expect(followStarts()).toEqual(['s1'])
})
// Same current again: no second pull.
b.svc.open(sid('s1'))
expect(followStarts()).toHaveLength(1)
// Stage moves: the new occupant opens.
b.svc.open(sid('s2'))
await vi.waitFor(() => {
expect(followStarts()).toEqual(['s1', 's2'])
})
})
it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async ({ bench }) => {
const storage = new Map<string, string>([
['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
])
vi.stubGlobal('localStorage', {
getItem: (k: string) => storage.get(k) ?? null,
setItem: (k: string, v: string) => { storage.set(k, v) },
})
try {
const b = bench()
expect(b.mock.log.requests(FOLLOW)).toEqual([])
await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
await vi.waitFor(() => {
expect(b.mock.log.requests(FOLLOW).map((request) => {
const address = (request as SessionFollowRequest).address
return String(address.kind === 'session' ? address.sessionId : address.childSessionId)
})).toEqual(['s1'])
})
} finally {
vi.unstubAllGlobals()
}
expect(b.svc.binding(sid('s1'))).toBeUndefined()
expect(b.svc.scope(sid('s2'))).toBeUndefined()
expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await reference.ready
using second = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await second.ready
expect(second.binding).toBe(reference.binding)
expect(b.mock.log.requests(FOLLOW)).toHaveLength(1)
})
})
describe('catalog-addressed navigation', () => {
it('opens an explicit catalog without retaining it and keeps one-shot labels optional', async ({ bench }) => {
const b = bench()
b.mock.remote.subagents.list.mockResolvedValue(ok({
entries: [
{ kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive', hasChildren: false },
{ kind: 'diagnostic', id: sid('missing'), reason: 'unavailable' },
],
parentAvailable: true,
}))
b.svc.setSubagentCatalogOpen(sid('root'), true)
await b.svc.refreshSubagents(sid('root'))
b.svc.setSubagentCatalogOpen(sid('root'), false)
expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe('one-shot')
expect(b.svc.list.getSnapshot().byId[sid('missing')]).toBeUndefined()
expect(b.svc.binding(sid('one-shot'))).toBeUndefined()
expect(b.mock.remote.subagents.list).toHaveBeenCalledOnce()
})
it('uses catalog labels for a listed addressed route', async ({ bench }) => {
const b = bench()
b.mock.remote.subagents.list.mockImplementation((payload) => {
@@ -863,15 +751,16 @@ describe('catalog-addressed navigation', () => {
])
await b.svc.refreshSubagents(sid('root'))
await b.svc.refreshSubagents(sid('child'))
b.svc.openSubagent({
using _reference = b.svc.retain({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
})
}, { source: 'controllerOperation' })
await _reference.ready
expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
})
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async ({ bench }) => {
it('projects a retained descendant and discovers ancestor addresses without retaining ancestor scopes', async ({ bench }) => {
const b = bench()
b.mock.remote.subagents.list.mockImplementation((payload) => {
const parentSessionId = payload
@@ -898,22 +787,23 @@ describe('catalog-addressed navigation', () => {
await feedList(b, [{ id: 'root' }])
await b.svc.refreshSubagents(sid('root'))
await b.svc.refreshSubagents(sid('child'))
b.svc.openSubagent({
using reference = b.svc.retain({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
})
}, { source: 'controllerOperation' })
await reference.ready
const list = b.svc.list.getSnapshot()
expect(list.ids).toEqual([sid('root')])
expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
b.svc.open(sid('child'))
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
expect(b.svc.subagentAddress(sid('child'))).toEqual({
parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
})
using child = b.svc.retain(sid('child'), { source: 'controllerOperation' })
await child.ready
expect(b.svc.binding(sid('child'))).toBe(child.binding)
expect(b.svc.binding(sid('grandchild'))).toBe(reference.binding)
})
})
@@ -932,16 +822,19 @@ describe('create', () => {
})
})
it('resolves with the session already listed and binding-resolvable (no flush wait)', async ({ bench }) => {
it('publishes a created identity without implicitly retaining its binding', async ({ bench }) => {
const b = bench()
b.mock.remote.session.create.mockResolvedValue(ok({ sessionId: sid('born') }))
const born = await b.svc.create({ workspaceId: 'ws' as never })
// Synchronously after resolution — the draft hand-off contract: the
// create echo IS the entity entering the client's view (blank row +
// resolvable scope/binding), no notifier flush in between.
// catalog publication), no notifier flush in between.
expect(b.svc.list.getSnapshot().byId[born]).toMatchObject({ id: 'born', blank: true })
expect(b.svc.binding(born)).toBeDefined()
expect(b.svc.scope(born)).toBeDefined()
expect(b.svc.binding(born)).toBeUndefined()
expect(b.svc.scope(born)).toBeUndefined()
using reference = b.svc.retain(born, { source: 'controllerOperation' })
await reference.ready
expect(b.svc.binding(born)).toBe(reference.binding)
})
it('lists the published id after Workspace attachment fails (publication precedes attachment)', async ({ bench }) => {
@@ -966,6 +859,17 @@ describe('create', () => {
})
describe('fork', () => {
it('propagates a failed fork without creating or retaining a child', async ({ bench }) => {
const b = bench()
const error = new RemoteError('session/not-found', 'source missing', { sessionId: sid('source') })
b.mock.remote.session.fork.mockResolvedValue(err(error))
const failure = await b.svc.fork({ sessionId: sid('source') }).catch((cause: unknown) => cause)
expect(failure).toBeInstanceOf(SessionForkError)
expect(failure).toMatchObject({ sourceSessionId: 'source', rpcError: error })
expect(b.svc.list.getSnapshot().ids).toEqual([])
expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
})
it.for([
['Roadmap', 'Roadmap (1)'],
['Roadmap (1)', 'Roadmap (2)'],
@@ -1020,7 +924,7 @@ describe('fork', () => {
expect(b.mock.remote.session.rename).not.toHaveBeenCalled()
})
it('rejects when child rename fails while keeping the published child addressable', async ({ bench }) => {
it('rejects child rename failure while preserving its catalog row and releasing the operation', async ({ bench }) => {
const b = bench()
b.svc.handleControlFrame({
type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2,
@@ -1031,25 +935,28 @@ describe('fork', () => {
await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
.rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
expect(b.svc.binding(sid('child'))).toBeDefined()
expect(b.svc.list.getSnapshot().byId[sid('child')]).toBeDefined()
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0)
})
})
describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async ({ bench }) => {
describe('catalog arrival', () => {
it('keeps a retained generation indexed after its Host row is removed', async ({ bench }) => {
const b = bench()
await feedList(b, [])
expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions
b.svc.handleSessionAdded({
sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
})
b.svc.handleSessionAdded({ sessionId: sid('s-new'), updatedAt: 1, running: false, blank: true })
await Promise.resolve()
const scoped = b.svc.scope(sid('s-new'))
expect(scoped).toBeDefined()
expect(scopeOf(scoped as Context)).toBe('s-new')
expect(b.svc.binding(sid('s-new'))).toBeUndefined()
const reference = b.svc.retain(sid('s-new'), { source: 'controllerOperation' })
await reference.ready
const binding = reference.binding
b.svc.handleSessionRemoved(sid('s-new'))
await Promise.resolve()
expect(b.svc.scope(sid('s-new'))).toBeUndefined()
expect(b.svc.list.getSnapshot().ids).not.toContain(sid('s-new'))
expect(b.svc.list.getSnapshot().byId[sid('s-new')]?.retainedBy).toEqual({ controllerOperation: 1 })
expect(b.svc.binding(sid('s-new'))).toBe(binding)
reference.release()
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toBeUndefined()
})
})
@@ -1058,6 +965,8 @@ describe('blank mirror', () => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true }])
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await _reference.ready
b.svc.handleSessionStatus(sid('s1'), true)
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true })
@@ -1068,7 +977,9 @@ describe('blank mirror', () => {
it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
const session = b.svc.binding(sid('s1'))!.session
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await reference.ready
const session = reference.binding.session
expect(session.getSnapshot().blank).toBe(true)
const gate = Promise.withResolvers<Awaited<ReturnType<typeof b.mock.remote.session.prompt>>>()
b.mock.remote.session.prompt.mockReturnValue(gate.promise)
@@ -1086,7 +997,9 @@ describe('blank mirror', () => {
it('keeps a rejected first prompt blank: hidden and still reusable', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
const session = b.svc.binding(sid('s1'))!.session
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await reference.ready
const session = reference.binding.session
b.mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('gateway/internal', 'agent busy', {})))
const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
expect(result.ok).toBe(false)
@@ -1113,7 +1026,9 @@ describe('blank mirror', () => {
it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1', blank: true }])
const session = b.svc.binding(sid('s1'))!.session
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await reference.ready
const session = reference.binding.session
await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
@@ -1133,46 +1048,12 @@ describe('coverage tails (branch duals)', () => {
expect(byId[sid('no-base')]?.title).toBeUndefined()
})
it('binding for an unknown session returns undefined and leaves the staged scope intact', async ({ bench }) => {
it('reading an unknown binding leaves an existing reference unchanged', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
await reference.ready
expect(b.svc.binding(sid('ghost'))).toBeUndefined()
// Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
await feedList(b, [])
expect(b.svc.scope(sid('s1'))).toBeDefined()
expect(b.svc.binding(sid('s1'))).toBe(reference.binding)
})
it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
await vi.waitFor(() => { expect(b.mock.log.requests(FOLLOW)).toHaveLength(1) })
await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
expect(b.svc.scope(sid('s1'))).toBeDefined()
// Resurfacing re-projects current = s1: same stage occupant, no second pull.
await feedList(b, [{ id: 's1' }])
expect(b.mock.log.requests(FOLLOW)).toHaveLength(1)
expect(b.svc.list.getSnapshot().current).toBe('s1')
})
it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 'a' }, { id: 'b' }])
b.svc.scope(sid('a'))
b.svc.open(sid('b')) // stage: b; both scoped
await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
// Move the stage to a THIRD id while b stays deferred: sweep walks a set
// containing b (torn).
await feedList(b, [{ id: 'c' }])
b.svc.open(sid('c'))
expect(b.svc.scope(sid('b'))).toBeUndefined()
// Deferral for an id whose record was never minted: force the deferral
// via removed list state — sweep must tolerate the missing record.
await feedList(b, []) // c removed while staged → deferred (scope exists)
await feedList(b, [{ id: 'd' }])
b.svc.open(sid('d')) // sweep tears c
expect(b.svc.scope(sid('c'))).toBeUndefined()
})
})
@@ -20,7 +20,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -30,9 +30,9 @@ function emptyWorkspaces() {
return bindSnapshotSelector(store)
}
type AttentionSnapshot = Parameters<Parameters<LanguageRowComponentProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<LanguageRowComponentProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: LanguageRowComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: LanguageRowComponentProps['useSessionStatus'] = selector => selector(noAttention)
function mount(active = 'en') {
// Real store instance — the sanctioned zero-machinery path for tests.
@@ -41,8 +41,8 @@ function mount(active = 'en') {
const setLocale = vi.fn()
const props: LanguageRowComponentProps = {
useSessions: emptySessions(),
useSessionPendingInteraction,
usePanelInfo, useResource,
useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(store),
actions: store.actions,
@@ -104,11 +104,15 @@ describe('client-resources apply', () => {
expect(hook).toBeTypeOf('function')
})
it('shares one address across Root and Session components without reopening on selection', async () => {
it('shares one address across independently bound views without reopening on rebinding', async () => {
runtime = await boot()
const rt = runtime
const firstId = await rt.sessions.add({ id: 'first-session' })
const secondId = await rt.sessions.add({ id: 'second-session' }, { current: false })
const secondId = await rt.sessions.add({ id: 'second-session' })
const firstReference = rt.sessions.retain(firstId)
await firstReference.ready
const secondReference = rt.sessions.retain(secondId)
await secondReference.ready
await rt.mount({ inject: [...inject], apply })
const opened = Promise.withResolvers<undefined>()
const open = vi.fn<ResourceProvider<'feed'>['open']>(async function* () {
@@ -140,20 +144,24 @@ describe('client-resources apply', () => {
return null
})
rt.renderSlot('resources.probe', {})
rt.renderSlot('resources.sessionProbe', {})
rt.renderSlot('resources.sessionPeer', {})
const firstView = rt.renderSlot('resources.sessionProbe', {}, { session: firstReference })
const secondView = rt.renderSlot('resources.sessionPeer', {}, { session: secondReference })
await act(async () => { await opened.promise })
const snapshot = source.getSnapshot()
expect(snapshot).toEqual({ status: 'live', value: 'shared data', failure: undefined })
expect(seen.root).toBe(snapshot)
expect(seen.first).toBe(snapshot)
expect(seen.second).toBe(snapshot)
expect([seen.firstSession, seen.secondSession]).toEqual([firstId, firstId])
expect([seen.firstSession, seen.secondSession]).toEqual([firstId, secondId])
expect(open).toHaveBeenCalledTimes(1)
expect(open.mock.calls[0]![0]).toBe(A)
expect(open.mock.calls[0]![1]).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal })
await rt.sessions.setCurrent(secondId)
const replacement = rt.sessions.retain(secondId)
await replacement.ready
firstView.update({}, { session: replacement })
firstReference.release()
secondView.update({})
expect([seen.firstSession, seen.secondSession]).toEqual([secondId, secondId])
expect(rt.ctx.resources.source(A)).toBe(source)
expect(seen.root).toBe(snapshot)
@@ -166,7 +166,7 @@ function declareConversation(slots: SlotRegistry): () => void {
return slots.register({
name: 'conversation',
children: {
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' },
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
} as never, () => null)
@@ -182,7 +182,7 @@ function uiWorkspaceDouble() {
}
/** A sessions double whose list can be moved and whose changes are pushed. */
function sessionsDouble(state: {
function sessionsDouble(ctx: Context, state: {
current?: string
byId: Record<string, {
id: string
@@ -191,14 +191,47 @@ function sessionsDouble(state: {
}>
}) {
const listeners = new Set<() => void>()
const bindings = new Map<string, { sessionId: SessionId; ctx: Context }>()
const snapshot = () => ({
...state,
byId: Object.fromEntries(Object.entries(state.byId).map(([id, row]) => [
id,
{
...row,
retainedBy: state.current === id ? { mainView: 1 } : {},
},
])),
})
return {
list: {
getSnapshot: () => state,
getSnapshot: snapshot,
subscribe: (fn: () => void) => {
listeners.add(fn)
return () => listeners.delete(fn)
},
},
binding: (id: string) => {
if (state.byId[id] === undefined) return undefined
let binding = bindings.get(id)
if (binding === undefined) {
binding = { sessionId: SessionId(id), ctx }
bindings.set(id, binding)
}
return binding
},
retainInfo: (id: string) => ({
getSnapshot: () => {
const retainedBy = snapshot().byId[id]?.retainedBy ?? {}
return {
referenceCount: Object.values(retainedBy).reduce((sum, count) => sum + count, 0),
retainedBy,
}
},
subscribe: (fn: () => void) => {
listeners.add(fn)
return () => listeners.delete(fn)
},
}),
/** Push a list change the way the runtime's store does. */
notify: () => { for (const fn of listeners) fn() },
}
@@ -211,12 +244,13 @@ describe('ui-agent-preset apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual([
'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
])
})
it('registers the settings section and no General row', async () => {
const { ctx, slots } = await bench()
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
@@ -233,6 +267,7 @@ describe('ui-agent-preset apply', () => {
it('registers into a declaration that arrives after apply', async () => {
const { ctx, slots } = await bench()
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
await ctx.plugin({ inject: [...inject], apply }).await()
declareRoot(slots)
@@ -242,6 +277,7 @@ describe('ui-agent-preset apply', () => {
it('hands the section its own store and default write', async () => {
const { ctx, slots } = await bench()
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
@@ -255,6 +291,7 @@ describe('ui-agent-preset apply', () => {
it('routes the section actions to one controller', async () => {
const { ctx, slots, calls } = await bench()
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
@@ -281,6 +318,7 @@ describe('ui-agent-preset apply', () => {
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
const { ctx, slots, calls, remote } = await bench()
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
@@ -288,13 +326,13 @@ describe('ui-agent-preset apply', () => {
const before = calls.length
remote.emit('settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
await vi.waitFor(() => { expect(calls.length).toBe(before + 3) })
const afterRelevant = calls.length
remote.emit('settings/document-updated', ['llm-deepseek', 1])
await Promise.resolve()
// Both surfaces re-read on their own namespace; an unrelated one moves
// The directory, section, and unbound seat re-read on their own namespace; an unrelated one moves
// neither, so this rules out a blanket refresh on every settings write.
expect(calls.length).toBe(afterRelevant)
})
@@ -304,7 +342,7 @@ describe('ui-agent-preset apply', () => {
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
@@ -320,6 +358,7 @@ describe('ui-agent-preset apply', () => {
it('leaves the section alone until it has been opened once', async () => {
const { ctx, slots, calls, remote } = await bench()
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const before = calls.length
@@ -327,9 +366,9 @@ describe('ui-agent-preset apply', () => {
remote.emit('settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
// Only the header label's roster reloads: a section nobody opened has
// The directory and unbound seat reload: a section nobody opened has
// nothing to converge, and reading the roster for it would be wasted.
expect(calls.length - before).toBe(1)
expect(calls.length - before).toBe(2)
})
it('registers the new-session chip and the header label, and drops both on disposal', async () => {
@@ -337,7 +376,7 @@ describe('ui-agent-preset apply', () => {
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply })
await fiber.await()
@@ -359,7 +398,7 @@ describe('ui-agent-preset apply', () => {
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
@@ -386,6 +425,74 @@ describe('ui-agent-preset apply', () => {
conversation()
})
it('keys bound seats by Provider generation and refreshes every live seat', async () => {
const { ctx, slots, calls, remote } = await bench()
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
const state: {
current?: string
byId: Record<string, {
id: string
blank: boolean
projectionValues?: { agentPreset?: string | null }
}>
} = {
current: 's1',
byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } },
}
const sessions = sessionsDouble(ctx, state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected
const first = injectSeat(SessionId('s1'))
const same = injectSeat(SessionId('s1'))
expect(same.hooks.agentPresetSeat).toBe(first.hooks.agentPresetSeat)
await first.load()
const beforeRefresh = calls.length
remote.emit('settings/document-updated', ['agent-presets', 1])
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(beforeRefresh + 2) })
const section = (slots.entries('settings.section')[0]!
.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
section.beginCopy('standard')
section.setCopyId('mine')
section.setCopyName('Mine')
await section.confirmCopy()
delete state.current
sessions.notify()
await first.load()
state.byId = {}
sessions.notify()
await first.load()
await ctx.fiber.dispose()
})
it('does not sync a blank Session that is not retained by the main view', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
const sessions = sessionsDouble(ctx, {
byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } },
})
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const section = (slots.entries('settings.section')[0]!
.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
await section.makeDefault('minimal')
expect(calls).not.toContain('select:minimal')
})
it('aligns Settings defaults with the current blank Session, never a running one', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
@@ -397,14 +504,14 @@ describe('ui-agent-preset apply', () => {
s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } },
},
}
const sessions = sessionsDouble(sessionState)
const sessions = sessionsDouble(ctx, sessionState)
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const section = (slots.entries('settings.section')[0]!
.inject as unknown as () => AgentPresetSectionInjected)()
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
.inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected)(SessionId('s1'))
await Promise.all([section.load(), seat.load()])
await section.makeDefault('minimal')
@@ -438,6 +545,7 @@ describe('ui-agent-preset apply', () => {
it('reloads Host truth after a picker-policy save failure', async () => {
const { ctx, slots, calls } = await bench({ failSettingsUpdate: true })
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!
@@ -458,7 +566,7 @@ describe('ui-agent-preset apply', () => {
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
@@ -496,12 +604,13 @@ describe('ui-agent-preset apply', () => {
projectionValues?: { agentPreset?: string | null }
}>
} = { byId: {} }
const sessions = sessionsDouble(state)
const sessions = sessionsDouble(ctx, state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
const chip = injectSeat()
await chip.load()
// Picked on the hero screen, where there is no session yet.
@@ -513,6 +622,8 @@ describe('ui-agent-preset apply', () => {
id: 's1', blank: true, projectionValues: { agentPreset: 'standard' },
}
sessions.notify()
const bound = injectSeat(SessionId('s1'))
await bound.load()
// Connecting a workspace produced the session; the stage reaches it there.
await vi.waitFor(() => { expect(calls).toContain('select:minimal') })
@@ -523,7 +634,7 @@ describe('ui-agent-preset apply', () => {
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
const sessions = sessionsDouble({
const sessions = sessionsDouble(ctx, {
current: 's1',
byId: { s1: { id: 's1', blank: true } },
})
@@ -531,7 +642,7 @@ describe('ui-agent-preset apply', () => {
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
.inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected)(SessionId('s1'))
await chip.load()
await chip.select('minimal')
@@ -552,12 +663,12 @@ describe('ui-agent-preset apply', () => {
s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } },
},
}
const sessions = sessionsDouble(state)
const sessions = sessionsDouble(ctx, state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
.inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected)(SessionId('s1'))
await chip.load()
await chip.select('minimal')
@@ -576,7 +687,7 @@ describe('ui-agent-preset apply', () => {
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const label = (slots.entries('conversation.session.header.actions')[0]!
@@ -592,13 +703,14 @@ describe('ui-agent-preset apply', () => {
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
const uiWorkspace = uiWorkspaceDouble()
ctx.provide('uiWorkspace', uiWorkspace as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
const seat = injectSeat()
await section.load()
await section.setPickerVisible(false)
@@ -625,6 +737,34 @@ describe('ui-agent-preset apply', () => {
conversation()
})
it('applies the creator preset to an existing blank main Session', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
const sessions = sessionsDouble(ctx, {
current: 's1',
byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } },
})
ctx.provide('sessions', sessions as never)
const uiWorkspace = uiWorkspaceDouble()
ctx.provide('uiWorkspace', uiWorkspace as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
const seat = injectSeat(SessionId('s1'))
await seat.load()
await section.load()
section.startCreatorDraft?.()
await vi.waitFor(() => { expect(calls).toContain('select:cordis') })
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
expect(uiWorkspace.starts).toHaveLength(1)
conversation()
})
it('keeps the applied composition when the roster load lands late', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
@@ -638,19 +778,21 @@ describe('ui-agent-preset apply', () => {
projectionValues?: { agentPreset?: string | null }
}>
} = { byId: {} }
const sessions = sessionsDouble(state)
const sessions = sessionsDouble(ctx, state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as () => AgentPresetSeatInjected)()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
await section.load()
section.startCreatorDraft?.()
state.current = 's1'
state.byId['s1'] = { id: 's1', blank: true }
sessions.notify()
const boundSeat = injectSeat(SessionId('s1'))
await boundSeat.load()
await vi.waitFor(() => { expect(calls).toContain('select:cordis') })
// The chip mounts with the flow's session, so its roster load can land
@@ -659,14 +801,15 @@ describe('ui-agent-preset apply', () => {
state.byId['s1'] = {
id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' },
}
await seat.load()
await boundSeat.load()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
expect(boundSeat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
conversation()
})
it('offers no creator draft while the conversation flow is absent', async () => {
const { ctx, slots } = await bench()
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
@@ -679,6 +822,14 @@ describe('ui-agent-preset apply', () => {
})
describe('AgentPresetSeatController reconciliation', () => {
it('does not capture a non-blank Session', () => {
const controller = new AgentPresetSeatController({} as never, () => ({
id: SessionId('started'), blank: false,
}))
expect(controller.blankSessionId()).toBeUndefined()
})
it('does not retarget a different blank Session after a Settings write', async () => {
const select = vi.fn(() => Promise.resolve({ ok: true as const, value: 'minimal' }))
let current = {
@@ -10,6 +10,8 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionRetainInfo } from '@deepseek-ai/dsh-api-session-controller/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx'
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
@@ -38,6 +40,8 @@ const SEAT_READY: AgentPresetSeatState = {
introduce: false,
}
const useSessionRetainInfo = <Selected,>(selector: (value: undefined) => Selected): Selected => selector(undefined)
/** The runtime's own `{name}` substitution, so a test reads the shown text. */
function translate(key: keyof typeof en, params?: Record<string, unknown>): string {
const template = en[key]
@@ -49,12 +53,17 @@ function translate(key: keyof typeof en, params?: Record<string, unknown>): stri
function renderSeat(
state: Partial<AgentPresetSeatState> = {},
select: () => Promise<string | undefined> = () => Promise.resolve(undefined),
session?: { id: string; retainInfo: SessionRetainInfo | undefined },
) {
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(select), introduced: vi.fn() }
render(<AgentPresetSeat {...({
...actions,
sessionId: session === undefined ? undefined : SessionId(session.id),
useAgentPresetSeat: bindSnapshotSelector(store),
useSessionRetainInfo: session === undefined
? useSessionRetainInfo
: <Selected,>(selector: (value: SessionRetainInfo | undefined) => Selected) => selector(session.retainInfo),
t: translate,
} as unknown as AgentPresetSeatProps)} />)
return actions
@@ -87,6 +96,17 @@ describe('the new-session chip', () => {
expect(screen.queryByRole('button')).toBeNull()
})
it('renders only for a Session retained by the main view', () => {
renderSeat({}, undefined, {
id: 's1', retainInfo: { referenceCount: 1, retainedBy: { mainView: 1 } },
})
expect(screen.getByRole('button')).toBeTruthy()
cleanup()
renderSeat({}, undefined, { id: 's1', retainInfo: undefined })
expect(screen.queryByRole('button')).toBeNull()
})
it('reads the roster once and shows the staged preset by name', async () => {
const actions = renderSeat()
@@ -34,7 +34,7 @@ const attachment = {
name: 'history.png',
}
type AttentionSnapshot = Parameters<Parameters<MessageImagesProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<MessageImagesProps['useSessionStatus']>[0]>[0]
type TrajectorySnapshot = Parameters<Parameters<MessageImagesProps['useTrajectory']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
@@ -46,7 +46,7 @@ const emptyTrajectory: TrajectorySnapshot = {
partial: null,
runningCalls: [],
}
const useSessionPendingInteraction: MessageImagesProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: MessageImagesProps['useSessionStatus'] = selector => selector(noAttention)
const useConversation: MessageImagesProps['useConversation'] = selector => selector(EMPTY_CONVERSATION_SNAPSHOT)
const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_SNAPSHOT)
const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory)
@@ -273,8 +273,8 @@ describe('ImageGallery', () => {
sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
useSession,
useSessions,
usePanelInfo, useResource,
useSessionPendingInteraction,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useSessionStatus,
useWorkspaces,
useProjection: () => undefined,
useConversation,
@@ -2,10 +2,10 @@
/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */
import { describe, expect, it, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import {
SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
} from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
@@ -56,20 +56,23 @@ async function bench() {
const openWorkspacePath = vi.fn<ClientRemote['session']['openWorkspacePath']>(
() => Promise.resolve({ ok: true, value: { opened: true } }),
)
new TestRemote(runtime.ctx, { session: { openWorkspacePath } })
runtime.remote.provideNamespaces({ session: { openWorkspacePath } })
const openSession = vi.fn<(id: SessionId) => void>()
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(ROOT)
runtime.sessions.open(ROOT)
openSession(ROOT)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
openSession,
} as never)
const session = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
session,
}, { current: false })
})
const rootReference = runtime.sessions.retain(ROOT)
await rootReference.ready
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
@@ -80,22 +83,23 @@ async function bench() {
await runtime.mount({ inject: [...injectChat], apply: applyChat })
runtime.renderRoot()
const chatViewApi = (id: SessionId) => {
const chatViewApi = (reference: SessionReference) => {
const id = reference.sessionId
const entry = runtime.slots.entries('conversation.view')[0]!
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
const instance = runtime.storeOf('conversation.view', reference) as ChatInstance
const injected = (entry.inject as unknown as (
sessionId: SessionId,
actions: ChatActions,
) => ChatViewInjected)(id, instance.actions)
return { instance, injected }
}
return { runtime, layout, openWorkspacePath, sidebarRight, session, chatViewApi }
return { runtime, layout, openWorkspacePath, sidebarRight, session, chatViewApi, rootReference, openSession }
}
describe('Chat inject API', () => {
it('loads older history and forks through the Session Controller', async () => {
const b = await bench()
const { injected } = b.chatViewApi(ROOT)
const { injected } = b.chatViewApi(b.rootReference)
injected.loadOlder()
expect(b.session.loadOlder).toHaveBeenCalledOnce()
@@ -104,7 +108,7 @@ describe('Chat inject API', () => {
injected.forkAt(17)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
expect(b.openSession).toHaveBeenCalledWith(ROOT)
})
expect(b.runtime.sessions.calls).toContainEqual({
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
@@ -120,7 +124,7 @@ describe('Chat inject API', () => {
it('addresses file paths under the Session\'s scope and opens them in the right Sidebar', async () => {
const b = await bench()
const { injected } = b.chatViewApi(ROOT)
const { injected } = b.chatViewApi(b.rootReference)
await injected.openFile('src/a.ts')
// Files stay in the product: a relative path is handed to the Sidebar as an
// address under this session's scope, not to a desktop opener.
@@ -143,7 +147,7 @@ describe('Chat inject API', () => {
it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => {
const b = await bench()
const { injected } = b.chatViewApi(ROOT)
const { injected } = b.chatViewApi(b.rootReference)
injected.openSkill('review')
const openReference = vi.fn(() => true)
const sessionOf = vi.fn(() => ({ openReference }))
@@ -164,8 +168,9 @@ describe('Chat inject API', () => {
id: NO_CWD,
summary: { title: 'N', displayTitle: 'N' },
session: sessionFakeFor(),
}, { current: false })
const { injected } = b.chatViewApi(NO_CWD)
})
using reference = b.runtime.sessions.retain(NO_CWD)
const { injected } = b.chatViewApi(reference)
// The Host resolves the relative path against the root it holds for the
// Session; the Client need not know it.
await injected.openFile('src/a.ts')
@@ -190,7 +195,7 @@ describe('Chat inject API', () => {
it('owns image loading, scroll memory, and optional closing-file mentions', async () => {
const b = await bench()
const { injected } = b.chatViewApi(ROOT)
const { injected } = b.chatViewApi(b.rootReference)
const owner = {} as never
expect(injected.fileMentions(owner)).toBeUndefined()
@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import {
SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
} from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -44,14 +44,15 @@ async function bench() {
} as never)
runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
const openSession = vi.fn<(id: SessionId) => void>()
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
runtime.sessions.open(SID)
openSession(SID)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
openSession,
} as never)
new TestRemote(runtime.ctx, {
runtime.remote.provideNamespaces({
session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })) },
})
const locale = new LocaleRuntime(runtime.ctx)
@@ -133,16 +134,16 @@ describe('Chat apply wiring', () => {
it('keeps the Chat standard source total while its target enters and leaves', async () => {
const b = await bench()
await b.runtime.sessions.add({ id: SID }, { current: false })
const binding = b.runtime.sessions.binding(SID)
if (binding === undefined) throw new Error('Chat source test Session binding is unavailable')
await b.runtime.sessions.add({ id: SID })
using reference = b.runtime.sessions.retain(SID)
const binding = reference.binding
const resolveSource = (owner: SessionBinding): ObservableSnapshot<ChatSnapshot> => {
const contribution = b.sourceDescriptor.resolve(owner) as {
hooks: { chat: ObservableSnapshot<ChatSnapshot> }
}
return contribution.hooks.chat
}
const source = b.runtime.ctx.uiSession.adapter.resolve(SID)!.hooks.chat as
const source = b.runtime.ctx.uiSession.adapter.bindingSource(reference).getSnapshot().hooks.chat as
ObservableSnapshot<ChatSnapshot>
expect(resolveSource(binding)).toBe(source)
expect(resolveSource(binding)).toBe(source)
@@ -20,7 +20,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -203,7 +203,7 @@ const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummar
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
return bindSnapshotSelector(store)
}
@@ -380,9 +380,10 @@ function makeHarness(
useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
useTrajectory: (() => { throw new Error('unused') }),
useSessions: emptySessions(),
useSessionRetainInfo: () => undefined,
useResource,
useSessionPendingInteraction: bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
useSessionStatus: bindSnapshotSelector(
createSnapshotStore<SessionStatusSnapshot>(new Map()),
),
useWorkspaces: emptyWorkspaces(),
useProjection: (key: string) => {
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -14,7 +14,7 @@ afterEach(cleanup)
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {},
}))
}
@@ -25,7 +25,7 @@ function emptyWorkspaces() {
}
function noPendingInteraction() {
return bindSnapshotSelector(createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()))
return bindSnapshotSelector(createSnapshotStore<SessionStatusSnapshot>(new Map()))
}
// The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
@@ -37,8 +37,9 @@ function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | t
const props: TranscriptViewRowProps = {
usePanelInfo: selector => selector({ activePanelId: null }),
useSessions: emptySessions(),
useSessionPendingInteraction: noPendingInteraction(),
useSessionStatus: noPendingInteraction(),
useWorkspaces: emptyWorkspaces(),
useSessionRetainInfo: () => undefined,
useResource,
useTranscriptView: bindSnapshotSelector(source),
setTranscriptView,
@@ -29,9 +29,16 @@ async function bench() {
},
})
const scopes = new Map<SessionId, Context>()
const bindings = new Map<SessionId, {
readonly sessionId: SessionId
readonly session: { readonly sessionId: SessionId }
readonly ctx: Context
}>()
ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id),
scopeOf: (c: Context) => scopeOf(c),
sessionOf: (c: Context) => bindings.get(scopeOf(c)!)?.session,
binding: (id: SessionId) => bindings.get(id),
subagentAddress: (id: SessionId) => id === sid('child')
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
@@ -49,8 +56,10 @@ async function bench() {
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const mint = (key: string) => {
const handle = createScope(ctx, sid(key))
scopes.set(sid(key), handle.ctx)
const id = sid(key)
const handle = createScope(ctx, id)
scopes.set(id, handle.ctx)
bindings.set(id, { sessionId: id, session: { sessionId: id }, ctx: handle.ctx })
return handle
}
return { ctx, fiber, sources, slots: ctx.slots, mint }
@@ -107,9 +107,12 @@ async function bench(opts: BenchOptions = {}) {
})
// Real scope tags behind a fake sessions face.
const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
const bindings = new Map<SessionId, { sessionId: SessionId; session: { sessionId: SessionId }; ctx: Context }>()
const removeSessions = ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id)?.ctx,
scopeOf: (c: Context) => scopeOf(c),
sessionOf: (scopeCtx: Context) => [...bindings.values()].find(binding => binding.ctx === scopeCtx)?.session,
binding: (id: SessionId) => bindings.get(id),
subagentAddress: (id: SessionId) => id === opts.addressed
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
@@ -140,8 +143,14 @@ async function bench(opts: BenchOptions = {}) {
const source = registered.get('/ command')
if (source === undefined) throw new Error('command source not registered')
const mint = (key: string) => {
const handle = createScope(ctx, sid(key))
scopes.set(sid(key), handle)
const id = sid(key)
const handle = createScope(ctx, id)
const binding = { sessionId: id, session: { sessionId: id }, ctx: handle.ctx }
scopes.set(id, handle)
bindings.set(id, binding)
handle.ctx.effect(() => () => {
if (bindings.get(id) === binding) bindings.delete(id)
})
return handle
}
/** Warm one session's catalog through the source's own candidate pull. */
@@ -1001,7 +1010,7 @@ describe('popupFor', () => {
const first = command.popupFor(a.ctx)
expect(command.popupFor(a.ctx)).toBe(first)
expect(command.popupFor(mint('s2').ctx)).not.toBe(first)
expect(() => command.popupFor(ctx)).toThrow('requires a session scope')
expect(() => command.popupFor(ctx)).toThrow('requires a retained Session scope')
})
it('a successful select dispatches the scoped consume-token and focuses the composer', async () => {
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client'
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import {
@@ -49,20 +49,37 @@ async function bench() {
}
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const connectWorkspace = vi.fn(async () => ROOT)
const references = new Map<SessionId, SessionReference>()
const opened = vi.fn<(id: SessionId) => void>()
let mainReference: SessionReference | undefined
const replaceMain = (id: SessionId, beforeOpen?: (id: SessionId) => void): void => {
const next = runtime.sessions.retain(id, { source: 'mainView' })
try {
beforeOpen?.(id)
} catch (error: unknown) {
next.release()
throw error
}
mainReference?.release()
mainReference = next
opened(id)
}
const openSession = vi.fn((id: SessionId) => { replaceMain(id) })
runtime.ctx.provide('uiWorkspace', {
openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
const id = await connectWorkspace()
beforeOpen(id)
runtime.sessions.open(id)
replaceMain(id, beforeOpen)
},
openSession: (id: SessionId) => { runtime.sessions.open(id) },
openSession,
} as never)
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
session: sessionFake,
}, { current: false })
})
const rootReference = runtime.sessions.retain(ROOT)
references.set(ROOT, rootReference)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
@@ -76,7 +93,7 @@ async function bench() {
runtime.slots.entries(key)[0]!
const conversationApi = (id: SessionId) => {
const entry = entryOf('conversation.session')
const instance = runtime.storeOf('conversation.session', id) as ConversationInstance
const instance = runtime.storeOf('conversation.session', references.get(id)) as ConversationInstance
const injected = (entry.inject as unknown as (
sessionId: SessionId,
actions: ConversationActions,
@@ -89,7 +106,7 @@ async function bench() {
}
const headerApi = (id: SessionId) => {
const entry = entryOf('conversation.session.header')
const instance = runtime.storeOf('conversation.session.header', id) as ConversationInstance
const instance = runtime.storeOf('conversation.session.header', references.get(id)) as ConversationInstance
const injected = (entry.inject as unknown as (
sessionId: SessionId,
actions: ConversationActions,
@@ -108,7 +125,7 @@ async function bench() {
conversationApi(id).injected.hooks.conversationViews
return {
runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi,
inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads,
inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, rootReference, references, opened,
}
}
@@ -199,7 +216,7 @@ describe('Conversation inject API', () => {
await b.runtime.dispose()
})
it('restores the selected View when a cached Session becomes current', async () => {
it('restores the selected View when a cached Session becomes Provider-bound', async () => {
const b = await bench()
const binding = b.runtime.ctx.uiConversation.binding(ROOT)
const activate = vi.spyOn(binding, 'activate')
@@ -214,7 +231,7 @@ describe('Conversation inject API', () => {
draft: '', view: 'custom', viewRequest: null,
}))
b.runtime.ctx.uiSession.adapter.resolve(ROOT)
b.runtime.ctx.uiSession.adapter.bindingSource(b.rootReference).getSnapshot()
expect(activate).toHaveBeenLastCalledWith('chat')
activate.mockClear()
@@ -223,10 +240,13 @@ describe('Conversation inject API', () => {
(() => null) as never,
)
await b.runtime.flush()
expect(activate).not.toHaveBeenCalled()
await b.runtime.sessions.setCurrent(ROOT)
expect(activate).toHaveBeenLastCalledWith('custom')
activate.mockClear()
using mainReference = b.runtime.sessions.retain(ROOT, { source: 'mainView' })
await b.runtime.flush()
expect(mainReference.sessionId).toBe(ROOT)
expect(activate).not.toHaveBeenCalled()
} finally {
removeCustom?.()
removeChat()
@@ -337,7 +357,7 @@ describe('Conversation inject API', () => {
b.connectWorkspace.mockResolvedValueOnce(ROOT)
await resident.selectWorkspace('workspace-1' as WorkspaceId)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
expect(b.opened).toHaveBeenCalledWith(ROOT)
expect(state.getSnapshot().draft).toBe('carry me')
const other = 'other-1' as SessionId
@@ -349,10 +369,10 @@ describe('Conversation inject API', () => {
},
}))
b.uploads.set(other, targetUpload)
await b.runtime.sessions.add({ id: other, session: {} }, { current: false })
await b.runtime.sessions.add({ id: other, session: {} })
b.connectWorkspace.mockResolvedValueOnce(other)
await resident.selectWorkspace('workspace-2' as WorkspaceId)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] })
expect(b.opened).toHaveBeenCalledWith(other)
expect(state.getSnapshot().draft).toBe('')
expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me')
await vi.waitFor(() => { expect(targetUpload).toHaveBeenCalledOnce() })
@@ -364,13 +384,13 @@ describe('Conversation inject API', () => {
const b = await bench()
b.connectWorkspace.mockResolvedValueOnce(ROOT)
await b.residentApi(undefined).selectWorkspace('workspace-0' as WorkspaceId)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
expect(b.opened).toHaveBeenCalledWith(ROOT)
const opens = b.runtime.sessions.calls.filter(call => call.method === 'open').length
const opens = b.opened.mock.calls.length
b.connectWorkspace.mockRejectedValueOnce(new Error('offline'))
await expect(b.residentApi(ROOT).selectWorkspace('workspace-4' as WorkspaceId))
.rejects.toThrow('offline')
expect(b.runtime.sessions.calls.filter(call => call.method === 'open')).toHaveLength(opens)
expect(b.opened).toHaveBeenCalledTimes(opens)
await b.runtime.dispose()
})
@@ -17,9 +17,8 @@ async function bench(options: { declareConversation?: boolean } = {}) {
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: unknown, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
runtime.sessions.open(SID)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
openSession: vi.fn(),
} as never)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
@@ -87,8 +86,9 @@ describe('target-neutral Conversation apply wiring', () => {
it('binds a cached locale-aware View roster only to its shell entries', async () => {
const b = await bench()
await b.runtime.sessions.add({ id: SID }, { current: false })
expect(b.runtime.ctx.uiSession.adapter.resolve(SID)?.hooks.conversationViews).toBeUndefined()
await b.runtime.sessions.add({ id: SID })
using reference = b.runtime.sessions.retain(SID)
expect(b.runtime.ctx.uiSession.adapter.bindingSource(reference).getSnapshot().hooks.conversationViews).toBeUndefined()
const header = b.runtime.slots.entries('conversation.session.header')[0]
const source = (header?.inject?.() as {
hooks: { conversationViews: ObservableSnapshot<readonly ViewTab[]> }
@@ -50,6 +50,23 @@ const LAYOUT_CHILDREN = {
'main': { kind: 'keyed', scope: 'root' },
} as const
function provideWorkspaceNavigation(runtime: SlotTestRuntime): (id: SessionId) => void {
let mainReference: ReturnType<typeof runtime.sessions.retain> | undefined
const openSession = (id: SessionId): void => {
const next = runtime.sessions.retain(id, { source: 'mainView' })
mainReference?.release()
mainReference = next
}
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
openSession(SID)
}),
openSession,
} as never)
return openSession
}
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
const [count, setCount] = useState(0)
return (
@@ -61,13 +78,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
async function bench(opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
runtime.sessions.open(SID)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
} as never)
const openSession = provideWorkspaceNavigation(runtime)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -81,6 +92,7 @@ async function bench(opts?: { blank?: boolean }) {
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
},
})
openSession(SID)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
return runtime
@@ -89,13 +101,7 @@ async function bench(opts?: { blank?: boolean }) {
describe('resident composer', () => {
it('renders the locked view state while no session exists at all', async () => {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
runtime.sessions.open(SID)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
} as never)
provideWorkspaceNavigation(runtime)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -122,13 +128,7 @@ describe('resident composer', () => {
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
runtime.sessions.open(SID)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
} as never)
const openSession = provideWorkspaceNavigation(runtime)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -159,6 +159,8 @@ describe('resident composer', () => {
summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true },
snapshot: { blank: true },
})
openSession(SID)
await runtime.flush()
expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody)
@@ -193,13 +195,7 @@ describe('resident composer', () => {
describe('prompt rejection through the assembled composer', () => {
it('renders the promptError alert strip and keeps the draft in the machine', async () => {
const runtime = await SlotTestRuntime.create()
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
runtime.sessions.open(SID)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
} as never)
const openSession = provideWorkspaceNavigation(runtime)
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -213,6 +209,7 @@ describe('prompt rejection through the assembled composer', () => {
summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
session: { prompt, loadOlder: vi.fn<ISession['loadOlder']>() },
})
openSession(SID)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
@@ -1,5 +1,5 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { createAssistantMessage, LlmAttemptId } from '@deepseek-ai/dsh-llm'
@@ -70,22 +70,27 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
const list = createSnapshotStore<SessionListState>({
ids: [],
byId: {},
current: undefined,
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
})
const sessions = {
const reference = {
sessionId: SESSION_ID,
binding,
ready: Promise.resolve(binding),
release: () => {},
[Symbol.dispose]() {},
}
const sessions: ISessions = {
list,
searchResultLimit: 50,
create: () => Promise.reject(new Error('unused fake Sessions operation')),
open: () => {},
openSubagent: () => {},
retain: () => reference,
using: async (_target, _options, operation) => await operation(reference),
retainInfo: () => createSnapshotStore({ referenceCount: 1, retainedBy: {} }),
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')),
@@ -93,7 +98,7 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
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 }
}
@@ -127,6 +132,7 @@ async function bootRegistries(): Promise<{
views: ConversationViewRegistry
}> {
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose() })
const { sessions, binding } = fakeSessions(ctx)
const uiConversation = new UiConversation(ctx, sessions)
return {
@@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx'
import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx'
@@ -22,7 +22,7 @@ afterEach(() => {
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {},
}))
}
@@ -33,7 +33,7 @@ function emptyWorkspaces() {
}
function noPendingInteraction() {
return bindSnapshotSelector(createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()))
return bindSnapshotSelector(createSnapshotStore<SessionStatusSnapshot>(new Map()))
}
function mount() {
@@ -42,7 +42,8 @@ function mount() {
const props: EnterBehaviorRowProps = {
usePanelInfo: selector => selector({ activePanelId: null }),
useSessions: emptySessions(),
useSessionPendingInteraction: noPendingInteraction(),
useSessionStatus: noPendingInteraction(),
useSessionRetainInfo: () => undefined,
useResource,
useWorkspaces: emptyWorkspaces(),
useBusyEnter: bindSnapshotSelector(policy.busyEnter),
@@ -13,13 +13,16 @@ describe('HistoricalImageCache', () => {
id: 's1',
session: { readAttachment: () => read.promise },
})
const reference = runtime.sessions.retain(sessionId)
await reference.ready
const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
const attachment = {
attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
} as const
const pending = cache.resolve(sessionId, attachment)
await runtime.sessions.remove(sessionId)
reference.release()
await runtime.flush()
read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed')
@@ -35,6 +38,8 @@ describe('HistoricalImageCache', () => {
const read = Promise.withResolvers<Awaited<ReturnType<SessionFace['readAttachment']>>>()
const runtime = await SlotTestRuntime.create()
const sessionId = await runtime.sessions.add({ id: 's1', session: { readAttachment: () => read.promise } })
const reference = runtime.sessions.retain(sessionId)
await reference.ready
const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
const attachment = {
attachmentId: AttachmentId('image-seeded'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
@@ -49,8 +54,8 @@ describe('HistoricalImageCache', () => {
expect(cache.peek(sessionId, attachment)).toBe('blob:canonical')
expect(revoked).toContain('blob:seeded')
await runtime.sessions.remove(sessionId)
await Promise.resolve()
reference.release()
await runtime.flush()
expect(revoked).toContain('blob:canonical')
await runtime.dispose()
} finally {
@@ -72,6 +77,8 @@ describe('HistoricalImageCache', () => {
} as never),
},
})
const reference = runtime.sessions.retain(sessionId)
await reference.ready
const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
const attachment = {
attachmentId: AttachmentId('image-missing'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
@@ -81,6 +88,7 @@ describe('HistoricalImageCache', () => {
await expect(cache.resolve(sessionId, attachment)).rejects.toThrow('attachment-invalid: missing')
expect(cache.peek(sessionId, attachment)).toBeUndefined()
expect(revoked).toHaveBeenCalledWith('blob:seeded')
reference.release()
await runtime.dispose()
} finally {
revoked.mockRestore()
@@ -170,11 +170,12 @@ function bench(over?: BenchOptions) {
SessionProvider: ({ children }) => children,
useSession: bindSnapshotSelector(session),
useConversation: bindSnapshotSelector(createSnapshotStore(conversationFixture())),
useSessionPendingInteraction: bindSnapshotSelector(createSnapshotStore(new Map())),
useSessionStatus: bindSnapshotSelector(createSnapshotStore(new Map())),
useSessionRetainInfo: () => undefined,
useResource,
useSessions: bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -14,7 +14,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import {
bindSnapshotSelector, conversationSnapshot, sessionSnapshot,
} from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SubmitAttachment, SubmitOutcome } from '../src/client/contract/input.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -56,9 +56,10 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
})),
useSessionPendingInteraction: bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
useSessionStatus: bindSnapshotSelector(
createSnapshotStore<SessionStatusSnapshot>(new Map()),
),
useSessionRetainInfo: () => undefined,
useResource,
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -23,7 +23,7 @@ import type {
import {
bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime,
} from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { DraftAttachmentId } from '../src/client/contract/input.ts'
@@ -123,6 +123,9 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
const ctx = runtime.ctx
const sessionId = 'scenario-s1' as SessionId
await runtime.sessions.add({ id: sessionId, summary: { cwd: '/w/a' } })
const reference = runtime.sessions.retain(sessionId)
await reference.ready
onTestFinished(() => { reference.release() })
await ctx.plugin(InputTriggerService).await()
const inputTriggers = ctx.get('inputTriggers') as InputTriggerService
register?.(inputTriggers)
@@ -147,9 +150,10 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
})),
useSessionPendingInteraction: bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
useSessionStatus: bindSnapshotSelector(
createSnapshotStore<SessionStatusSnapshot>(new Map()),
),
useSessionRetainInfo: () => undefined,
useResource,
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -19,7 +19,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import {
bindSnapshotSelector, conversationSnapshot, makeTranslate,
} from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { InputState } from '../src/client/contract/input.ts'
import { zh } from '../src/client/locales.ts'
@@ -103,9 +103,10 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial<QueueDockInjected>
t,
usePanelInfo,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useSessionRetainInfo: () => undefined,
useResource,
useSessionPendingInteraction: bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
useSessionStatus: bindSnapshotSelector(
createSnapshotStore<SessionStatusSnapshot>(new Map()),
),
useWorkspaces: (() => { throw new Error('unused') }) as never,
useProjection: (() => undefined) as never,
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/** Exercises Conversation persistence through the real SlotRegistry store axis. */
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, onTestFinished } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
@@ -12,6 +12,7 @@ type ConversationInstance = ReturnType<ReturnType<typeof createConversationStore
async function createBench() {
const runtime = await SlotTestRuntime.create()
onTestFinished(() => runtime.dispose())
const conversation = createConversationStore()
await runtime.root.declare({
'conversation.session': { kind: 'single', scope: 'session' },
@@ -26,9 +27,9 @@ async function createBench() {
function storeFor(
current: Awaited<ReturnType<typeof createBench>>,
slot: 'conversation.session' | 'conversation.session.header',
sessionId: SessionId,
reference: ReturnType<typeof current.runtime.sessions.retain>,
): ConversationInstance {
return current.runtime.storeOf(slot, sessionId) as ConversationInstance
return current.runtime.storeOf(slot, reference) as ConversationInstance
}
beforeEach(() => {
@@ -39,8 +40,11 @@ describe('Conversation state survives on its store seat', () => {
it('shares one instance between the Session body and header', async () => {
const b = await createBench()
await b.runtime.sessions.add({ id: 's1' })
const body = storeFor(b, 'conversation.session', sid('s1'))
const header = storeFor(b, 'conversation.session.header', sid('s1'))
using reference = b.runtime.sessions.retain(sid('s1'))
await reference.ready
expect(reference.sessionId).toBe(sid('s1'))
const body = storeFor(b, 'conversation.session', reference)
const header = storeFor(b, 'conversation.session.header', reference)
body.actions.setDraft('half-typed')
header.actions.setView('trajectory')
@@ -54,35 +58,46 @@ describe('Conversation state survives on its store seat', () => {
const b = await createBench()
const oneId = sid('s1')
await b.runtime.sessions.add({ id: 's1' })
using retained = b.runtime.sessions.retain(oneId)
await retained.ready
await b.runtime.sessions.add({ id: 's2' })
const one = storeFor(b, 'conversation.session', oneId)
const two = storeFor(b, 'conversation.session', sid('s2'))
using second = b.runtime.sessions.retain(sid('s2'))
await second.ready
const one = storeFor(b, 'conversation.session', retained)
const two = storeFor(b, 'conversation.session', second)
one.actions.setDraft('only one')
two.actions.setDraft('only two')
await b.runtime.sessions.updateSummary(oneId, { displayTitle: 'projected' })
expect(storeFor(b, 'conversation.session', oneId)).toBe(one)
expect(storeFor(b, 'conversation.session', retained)).toBe(one)
expect(one.store.getSnapshot().draft).toBe('only one')
expect(two.store.getSnapshot().draft).toBe('only two')
await b.runtime.dispose()
})
it('buries the instance and persisted draft with the Session scope', async () => {
it('recreates the instance and restores persisted state after the Session scope ends', async () => {
const b = await createBench()
await b.runtime.sessions.add({ id: 's1' })
const doomed = storeFor(b, 'conversation.session', sid('s1'))
using reference = b.runtime.sessions.retain(sid('s1'))
await reference.ready
const doomed = storeFor(b, 'conversation.session', reference)
doomed.actions.setDraft('to be buried')
doomed.actions.setView('chat')
expect(localStorage.getItem('dsh.conversation.s1')).not.toBeNull()
reference.release()
await b.runtime.flush()
await b.runtime.sessions.remove('s1')
expect(localStorage.getItem('dsh.conversation.s1')).toBeNull()
expect(localStorage.getItem('dsh.conversation.s1')).not.toBeNull()
await b.runtime.sessions.add({ id: 's1' })
const reborn = storeFor(b, 'conversation.session', sid('s1'))
using replacement = b.runtime.sessions.retain(sid('s1'))
await replacement.ready
expect(replacement.binding).toBe(b.runtime.sessions.binding(sid('s1')))
const reborn = storeFor(b, 'conversation.session', replacement)
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ draft: '', view: null, viewRequest: null })
expect(reborn.store.getSnapshot()).toEqual({ draft: 'to be buried', view: 'chat', viewRequest: null })
await b.runtime.dispose()
})
})
@@ -36,6 +36,8 @@ async function bench(maxConcurrentFileUploads = 2) {
id: 's1',
session: { prompt, updateQueue, cancel, loadOlder },
})
const reference = runtime.sessions.retain('s1' as SessionId)
await reference.ready
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
const hub = new InputHub(runtime.ctx, makeTranslate(zh, {}))
@@ -48,10 +50,28 @@ async function bench(maxConcurrentFileUploads = 2) {
const root = runtime.ctx.get('conversation') as ConversationController
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationController
const shell = hub.shellFor(runtime.sessions.binding('s1')!)
return { runtime, fiber, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
return { runtime, fiber, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder, reference }
}
describe('ConversationController', () => {
it('does not revive a withdrawn generation when an old input submits before scoped cleanup', async () => {
const b = await bench()
try {
b.shell.setDraft('old draft')
b.reference.release()
const retain = vi.spyOn(b.runtime.sessions, 'retain')
b.shell.submit()
b.shell.steerQueue()
expect(b.runtime.sessions.binding('s1')).toBeUndefined()
expect(retain).not.toHaveBeenCalled()
await b.runtime.flush()
expect(b.prompt).not.toHaveBeenCalled()
retain.mockRestore()
} finally {
await b.runtime.dispose()
}
})
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello')
@@ -107,7 +127,8 @@ describe('ConversationController', () => {
])
if (attachment === undefined) throw new Error('draft attachment missing')
b.root.input.for(b.runtime.sessions.scope('s1')!).addAttachments([attachment.id])
await b.runtime.sessions.remove('s1')
b.reference.release()
await b.runtime.flush()
expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
expect(revoked).toHaveBeenCalledWith('blob:draft-1')
} finally {
@@ -117,7 +138,7 @@ describe('ConversationController', () => {
await b.runtime.dispose()
})
it('releases an image removed from the rail by an unsettled optimistic send', async () => {
it('releases an unsettled send preview during structural Session teardown', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
@@ -129,7 +150,7 @@ describe('ConversationController', () => {
b.shell.addAttachments([attachment.id])
b.shell.submit()
expect(b.shell.snapshot.attachmentIds).toEqual([])
await b.runtime.sessions.remove('s1')
await b.runtime.sessions.disposeScopes()
expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
expect(revoked).toHaveBeenCalledWith('blob:detached')
} finally {
@@ -225,7 +246,7 @@ describe('ConversationController', () => {
const drafts = b.root.createDrafts(session.sessionId, ['one', 'two', 'three', 'four', 'removed'].map(name =>
new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' })))
expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt'])
await vi.waitFor(() => { expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt']) })
await expect(b.root.serializeDraftAttachments([drafts[2]!.id]))
.rejects.toThrow('one or more files have not finished uploading')
b.root.releaseDraftAttachment(drafts[4]!.id)
@@ -284,14 +305,14 @@ describe('ConversationController', () => {
prompt: b.prompt, updateQueue: b.updateQueue, cancel: b.cancel, loadOlder: b.loadOlder,
},
})
b.runtime.sessions.open('s2' as never)
using other = b.runtime.sessions.retain('s2' as SessionId)
await other.ready
reportProgress?.({ loaded: 3, total: 8 })
expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({
status: 'uploading', loaded: 3, total: 8,
})
expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id])
b.runtime.sessions.open('s1' as never)
settled.resolve({
ok: true,
value: {
@@ -335,6 +356,8 @@ describe('ConversationController', () => {
})),
}
await b.runtime.sessions.add({ id: 's2', session: target })
using _target = b.runtime.sessions.retain('s2' as SessionId)
await _target.ready
b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id])
expect(sourceSignal?.aborted).toBe(true)
@@ -447,7 +470,8 @@ describe('ConversationController', () => {
it('fails loudly from the root scope, on an unbound session, or without Client Sessions', async () => {
const b = await bench()
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
await b.runtime.sessions.remove('s1')
b.reference.release()
await b.runtime.flush()
await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/)
await b.runtime.dispose()
// No Client Sessions service at all: a bare context lacks the assembled controller.
@@ -468,8 +492,10 @@ describe('sendSession submission echo', () => {
const b = await bench()
const retire: { onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined } = {}
const abandon = vi.fn()
const begun = Promise.withResolvers<BeginSubmissionInput>()
const beginSubmission = vi.fn((input: BeginSubmissionInput) => {
retire.onRetire = input.onRetire
begun.resolve(input)
return { requestId: 'req-echo' as never, abandon }
})
await b.runtime.sessions.updateSessionSnapshot('s1', () => {})
@@ -481,7 +507,7 @@ describe('sendSession submission echo', () => {
created.mockRestore()
revoked.mockRestore()
}
return { ...b, beginSubmission, abandon, retire, revoked, restore }
return { ...b, beginSubmission, begun: begun.promise, abandon, retire, revoked, restore }
}
it('registers the echo before serialization and prompts with its identity', async () => {
@@ -492,8 +518,7 @@ describe('sendSession submission echo', () => {
])
const session = b.runtime.sessions.binding('s1')!.session
const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue')
// Synchronous: the echo is registered before any encoding starts.
const echo = b.beginSubmission.mock.calls[0]?.[0]
const echo = await b.begun
expect(echo?.mode).toBe('queue')
expect(echo?.text).toBe('带图')
expect(echo?.attachments).toHaveLength(1)
@@ -542,7 +567,7 @@ describe('sendSession submission echo', () => {
expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready')
})
const sending = b.root.sendSession(session, 'ordered', drafts.map(draft => draft.id), 'steer')
const echo = b.beginSubmission.mock.calls[0]?.[0]
const echo = await b.begun
expect(echo?.mode).toBe('steer')
expect(echo?.attachments.map(attachment => attachment.type === 'image'
? { type: attachment.type, name: attachment.value.name }
@@ -11,7 +11,7 @@ import {
bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture,
} from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
@@ -130,14 +130,14 @@ function mount(
) {
const root = sid('root')
const parent = sid('parent')
const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
const rootRow = { id: root, displayTitle: 'Root', running: false, retainedBy: {}, blank: false, updatedAt: 1 }
const parentRow = {
id: parent, displayTitle: 'Parent', parentId: root, origin: 'subagent' as const,
running: false, blank: false, updatedAt: 2,
running: false, retainedBy: {}, blank: false, updatedAt: 2,
}
const childRow = {
id: SID, displayTitle: 'Child', parentId: options.nestedSubagent === true ? parent : root,
cwd: '/projects/one', running: false, blank: options.summaryBlank ?? false, updatedAt: 3,
cwd: '/projects/one', running: false, retainedBy: { mainView: 1 }, blank: options.summaryBlank ?? false, updatedAt: 3,
...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
}
const listed = options.omitSummaryRow !== true
@@ -150,16 +150,15 @@ function mount(
...listed && options.nestedSubagent === true && { [parent]: parentRow },
...listed && { [SID]: childRow },
},
current: SID,
phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
phase: 'ready', subagentsByParent: {}, jobsBySession: {},
})
const workspaces = createSnapshotStore<WorkspaceSnapshot>(workspaceState(workspaceRows))
const session = createSnapshotStore<SessionSnapshot>(snapshot)
const useSession = bindSnapshotSelector(session)
const conversation = createSnapshotStore<ConversationSnapshot>(EMPTY_CONVERSATION_SNAPSHOT)
const useConversation = bindSnapshotSelector(conversation)
const useSessionPendingInteraction = bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
const useSessionStatus = bindSnapshotSelector(
createSnapshotStore<SessionStatusSnapshot>(new Map()),
)
const store = createConversationStore().create()
store.actions.setDraft('ordinary draft')
@@ -201,7 +200,8 @@ function mount(
useSessions={props.useSessions}
usePanelInfo={props.usePanelInfo}
useResource={useResource}
useSessionPendingInteraction={useSessionPendingInteraction}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useWorkspaces={props.useWorkspaces}
useProjection={(() => undefined)}
useInput={useInput}
@@ -228,7 +228,8 @@ function mount(
useSessions={props.useSessions}
usePanelInfo={props.usePanelInfo}
useResource={useResource}
useSessionPendingInteraction={useSessionPendingInteraction}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useWorkspaces={props.useWorkspaces}
useProjection={(() => undefined)}
useInput={useInput}
@@ -254,7 +255,8 @@ function mount(
useConversation={useConversation}
useSessions={props.useSessions}
usePanelInfo={props.usePanelInfo}
useSessionPendingInteraction={useSessionPendingInteraction}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useWorkspaces={props.useWorkspaces}
useProjection={(() => undefined)}
useInput={useInput}
@@ -303,7 +305,8 @@ function mount(
useSession,
useConversation,
useSessions: bindSnapshotSelector(sessions),
useSessionPendingInteraction,
useSessionStatus,
useSessionRetainInfo: () => undefined,
useResource,
useWorkspaces: bindSnapshotSelector(workspaces),
useProjection: (() => undefined),
@@ -38,7 +38,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) {
controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} }
return {
useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
reloadPresentedHost: vi.fn(() => controller.loadHost()),
@@ -28,10 +28,14 @@ async function bench() {
)
// Sessions face: mint one real scope for session 'a' and resolve it by id.
const scope = createScope(ctx, sid('a'))
const session = { sessionId: sid('a') }
const binding = { sessionId: sid('a'), session, ctx: scope.ctx }
ctx.provide('sessions', {
scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
scopeOf: (c: Context) => scopeOf(c),
})
sessionOf: (c: Context) => c === scope.ctx ? session : undefined,
binding: (id: SessionId) => id === sid('a') ? binding : undefined,
} as never)
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
@@ -83,13 +83,22 @@ function controllerBench(sources: InputTriggerSource[] = [], key = 'a') {
/** Real-service bench: a sessions face resolving scope tags to session ids. */
async function serviceBench() {
const root = new Context()
const bindings = new Map<SessionId, { sessionId: SessionId; session: { sessionId: SessionId }; ctx: Context }>()
root.provide('sessions', {
scopeOf: (c: Context) => scopeOf(c),
})
sessionOf: (ctx: Context) => [...bindings.values()].find(binding => binding.ctx === ctx)?.session,
binding: (id: SessionId) => bindings.get(id),
} as never)
await root.plugin(InputTriggerService).await()
const inputTriggers = root.get('inputTriggers') as InputTriggerService
const mint = (key: string) => {
const scope = createScope(root, sid(key))
const id = sid(key)
const scope = createScope(root, id)
const binding = { sessionId: id, session: { sessionId: id }, ctx: scope.ctx }
bindings.set(id, binding)
scope.ctx.effect(() => () => {
if (bindings.get(id) === binding) bindings.delete(id)
})
return { actx: scope.ctx, fiber: scope.fiber }
}
return { root, inputTriggers, mint }
@@ -174,7 +183,7 @@ describe('sessionOf', () => {
it('throws off an unscoped context', async () => {
const { root, inputTriggers } = await serviceBench()
expect(() => inputTriggers.sessionOf(root)).toThrow(/requires a session scope/)
expect(() => inputTriggers.sessionOf(root)).toThrow(/requires a retained Session scope/)
})
it('warms the roster once at controller birth with the session projection', async () => {
@@ -39,11 +39,9 @@ function props(jobs: readonly JobView[] | undefined): JobListActionProps {
const state = {
ids: [SESSION],
byId: {},
current: SESSION,
phase: 'ready',
subagentsByParent: {},
jobsBySession: jobs === undefined ? {} : { [SESSION]: jobs },
currentAddress: undefined,
} satisfies SessionListState
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
return select(state)
@@ -15,9 +15,9 @@ const useResource = (() => ({ status: 'none' as const, value: undefined, failure
let selectedSession: SessionId | undefined
let selectedSessionTitle: string | undefined
let workspacesReady = true
type AttentionSnapshot = Parameters<Parameters<AppFrameProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<AppFrameProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: AppFrameProps['useSessionStatus'] = selector => selector(noAttention)
let observers: ResizeObserverStub[]
class ResizeObserverStub {
@@ -72,15 +72,13 @@ function mountFrame(windowWidth = frameWidth) {
ids: selectedSession === undefined ? [] : [selectedSession],
byId: selectedSession === undefined ? {} : {
[selectedSession]: {
id: selectedSession, displayTitle: 'Test', running: false, blank: false, updatedAt: 1,
id: selectedSession, displayTitle: 'Test', running: false, retainedBy: { mainView: 1 }, blank: false, updatedAt: 1,
...(selectedSessionTitle === undefined ? {} : { title: selectedSessionTitle }),
},
},
current: selectedSession,
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
})
const workspaceState: WorkspaceSnapshot = {
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -98,7 +96,8 @@ function mountFrame(windowWidth = frameWidth) {
renderSlot={renderSlot}
useSessions={useSessions}
usePanelInfo={usePanelInfo}
useSessionPendingInteraction={useSessionPendingInteraction}
useSessionStatus={useSessionStatus}
useSessionRetainInfo={() => undefined}
useResource={useResource}
useWorkspaces={sel => sel(workspaceState)}
t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key}
@@ -18,12 +18,10 @@ function titleSources() {
const sessionId = 'session-title' as SessionId
const sessions = createSnapshotStore<SessionListState>({
ids: [sessionId],
byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } },
current: sessionId,
byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, retainedBy: { mainView: 1 }, blank: false, updatedAt: 1 } },
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
})
const panelInfo = createSnapshotStore<PanelInfo>({ activePanelId: null })
return {
@@ -42,7 +40,13 @@ describe('DocumentTitle', () => {
expect(document.title).toBe('First title — DeepSeek Harness')
act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Revised title' }) })
expect(document.title).toBe('Revised title — DeepSeek Harness')
act(() => { sessions.update((state) => { state.current = undefined }) })
act(() => {
const state = sessions.getSnapshot()
sessions.set({
...state,
byId: { ...state.byId, [sessionId]: { ...state.byId[sessionId]!, retainedBy: {} } },
})
})
expect(document.title).toBe('DeepSeek Harness')
mounted.unmount()
expect(document.title).toBe('DeepSeek Harness')
@@ -68,7 +72,7 @@ describe('DocumentTitle', () => {
expect(document.title).toBe('Product')
act(() => { panelInfo.set({ activePanelId: 'panel-b' as MainPanelId }) })
expect(document.title).toBe('Product')
expect(sessions.getSnapshot().current).toBe(sessionId)
expect(sessions.getSnapshot().byId[sessionId]?.retainedBy.mainView).toBe(1)
act(() => { panelInfo.set({ activePanelId: null }) })
expect(document.title).toBe('Updated title — Product')
})
@@ -134,20 +134,15 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
localeRuntime.setLocale(locale)
ctx.provide('locale', localeRuntime)
const scopes = new Map<SessionId, Context>()
const bindings = new Map<SessionId, {
sessionId: SessionId
session: { sessionId: SessionId; projections: { faceOf: () => SnapshotStore<ModelSelectionProjection | undefined> } }
ctx: Context
}>()
const addressed = new Set<SessionId>()
ctx.provide('sessions', {
scope: (id: SessionId) => scopes.get(id),
binding: (id: SessionId) => {
const scope = scopes.get(id)
const projection = projections.get(id)
return scope === undefined || projection === undefined
? undefined
: {
sessionId: id,
session: { projections: { faceOf: () => projection } },
ctx: scope,
}
},
binding: (id: SessionId) => bindings.get(id),
subagentAddress: (id: SessionId) => addressed.has(id)
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
@@ -159,10 +154,20 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
const id = sid(key)
const handle = createScope(ctx, id)
scopes.set(id, handle.ctx)
projections.set(id, createSnapshotStore<ModelSelectionProjection | undefined>({
const projection = createSnapshotStore<ModelSelectionProjection | undefined>({
lastUsed: null,
next: null,
}))
})
projections.set(id, projection)
const binding = {
sessionId: id,
session: { sessionId: id, projections: { faceOf: () => projection } },
ctx: handle.ctx,
}
bindings.set(id, binding)
handle.ctx.effect(() => () => {
if (bindings.get(id) === binding) bindings.delete(id)
})
return handle
}
return {
@@ -55,13 +55,13 @@ function ok<T>(value: T) {
const dictionary: Record<string, string> = zh
const t: PermissionRowProps['t'] = key => dictionary[key] ?? key
type AttentionSnapshot = Parameters<Parameters<PermissionRowProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<PermissionRowProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: PermissionRowProps['useSessionStatus'] = selector => selector(noAttention)
const runtime = {
useSessions: (() => { throw new Error('unused') }) as never,
useSessionPendingInteraction,
usePanelInfo, useResource,
useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useWorkspaces: (() => { throw new Error('unused') }) as never,
}
@@ -71,7 +71,8 @@ describe('ui-plugin-manager browser plugin', () => {
expect(icon.component).toBe(PluginsPanelIcon)
const unread = () => { throw new Error('The sidebar icon must not read application state') }
const glyph = render(<PluginsPanelIcon size={18} active={false}
usePanelInfo={unread} useSessions={unread} useSessionPendingInteraction={unread} useWorkspaces={unread} useResource={unread} />)
usePanelInfo={unread} useSessions={unread} useSessionStatus={unread} useSessionRetainInfo={unread}
useWorkspaces={unread} useResource={unread} />)
expect(glyph.container.querySelector('svg')?.getAttribute('width')).toBe('18')
expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 })
expect(icon.locale).toBe(NS)
@@ -511,9 +511,10 @@ describe('host face', () => {
const bench = await boot()
const host = captureHost(bench)
const absent = { key: undefined, hooks: {}, keyedHooks: {}, props: {} }
const source = { getSnapshot: () => absent, subscribe: () => () => undefined }
const adapter = {
current: { getSnapshot: () => absent, subscribe: () => () => undefined },
resolve: () => undefined,
current: source,
bindingSource: () => source,
}
const changed = vi.fn()
host.scopeRevision.subscribe(changed)
@@ -606,7 +607,7 @@ describe('store instance axis', () => {
// resolution is covered through the cascade spec below.
})
it('clears a materialized per-session instance with its binding lifetime', async () => {
it('drops a materialized per-session instance without clearing persistence', async () => {
const { bench, host } = await storeBench()
const { handle, created } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
@@ -615,13 +616,13 @@ describe('store instance axis', () => {
const s1 = host.storeOf(entry as never, scope.binding)
expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
await scope.fiber.dispose()
expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
expect(created[0]?.clearPersisted).not.toHaveBeenCalled()
const replacement = scopedBinding(bench.ctx, 's1')
expect(host.storeOf(entry as never, replacement.binding)).not.toBe(s1)
await replacement.fiber.dispose()
})
it('clears persisted state for scoped stores that were never materialized', async () => {
it('does not materialize scoped stores solely for scope release', async () => {
const { bench } = await storeBench()
const root = fakeHandle()
const scoped = fakeHandle()
@@ -633,14 +634,12 @@ describe('store instance axis', () => {
await scope.fiber.dispose()
expect(root.handle.create).not.toHaveBeenCalled()
expect(scoped.handle.create).toHaveBeenCalledOnce()
expect(scoped.handle.create).toHaveBeenCalledWith('s1')
expect(scoped.created[0]?.clearPersisted).toHaveBeenCalledOnce()
expect(scoped.handle.create).not.toHaveBeenCalled()
})
it('leaves scoped Store cleanup with the newest Context generation', async () => {
const { bench } = await storeBench()
const { handle, created } = fakeHandle()
const { handle } = fakeHandle()
bench.erased.register({ name: 't.panel', store: handle }, C)
const first = scopedBinding(bench.ctx, 's1')
const replacement = scopedBinding(bench.ctx, 's1')
@@ -652,19 +651,14 @@ describe('store instance axis', () => {
expect(handle.create).not.toHaveBeenCalled()
await replacement.fiber.dispose()
expect(handle.create).toHaveBeenCalledOnce()
expect(created[0]?.clearPersisted).toHaveBeenCalledOnce()
expect(handle.create).not.toHaveBeenCalled()
})
it('clears session-maybe state through binding disposal and creates a fresh instance on reuse', async () => {
it('drops session-maybe state without clearing persistence and creates a fresh instance on reuse', async () => {
const { bench, host } = await storeBench()
bench.svc.installScope('session', {
current: {
getSnapshot: () => ({ key: undefined, hooks: {}, keyedHooks: {}, props: {} }),
subscribe: () => () => undefined,
},
resolve: () => undefined,
})
const absent = { key: undefined, hooks: {}, keyedHooks: {}, props: {} }
const source = { getSnapshot: () => absent, subscribe: () => () => undefined }
bench.svc.installScope('session', { current: source, bindingSource: () => source })
const { handle, created } = fakeHandle()
bench.erased.register({ name: 't.maybe', store: handle }, C)
const [entry] = host.entriesOf('t.maybe')
@@ -673,7 +667,7 @@ describe('store instance axis', () => {
await scope.fiber.dispose()
expect(created[0]?.clearPersisted).toHaveBeenCalledOnce()
expect(created[0]?.clearPersisted).not.toHaveBeenCalled()
const replacement = scopedBinding(bench.ctx, 's1')
const after = host.storeOf(entry as never, replacement.binding)
expect(after).not.toBe(before)
@@ -41,7 +41,7 @@ function hostOver(core: SlotCore): SlotRendererHost {
}
const sessionAdapter: SlotScopeAdapter = {
current: bindingSource,
resolve: () => undefined,
bindingSource: () => bindingSource,
}
return {
subscribe: (key, fn) => core.subscribe(key, fn),
@@ -12,6 +12,8 @@ import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import { useEffect, useState, type ReactNode } from 'react'
import { Context } from '@deepseek-ai/cordis'
import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import {
SlotOwnershipError, StaleAuthorizationError,
type ActionsDecl, type SessionProviderComponent, type SlotEntryDef,
@@ -86,7 +88,6 @@ function observable<T>(initial: T) {
* ledger; session bindings are identity-stable per id.
*/
function makeHost() {
const scopeCtx = new Context()
const entries = new Map<string, StoredEntry[]>()
const specs = new Map<string, DeclaredSpec>()
const versions = new Map<string, number>()
@@ -102,9 +103,11 @@ function makeHost() {
keyedHooks: {},
props: { sessionId: undefined },
}
const absent = observable<StandardSourceBinding>(absentBinding)
const currentBinding = observable<StandardSourceBinding>(absentBinding)
const targets = new Map<string, SessionReference>()
const bindingSources = new Map<string, ReturnType<typeof observable<StandardSourceBinding>>>()
let currentId: string | undefined
const bindings = new Map<string, ScopedStandardSourceBinding>()
const sessionSources = new Map<string, ReturnType<typeof observable<unknown>>>()
const root = observable<StandardSourceBinding>({
key: undefined,
@@ -114,7 +117,7 @@ function makeHost() {
})
const sessionAdapter: SlotScopeAdapter = {
current: currentBinding,
resolve: key => bindings.get(key),
bindingSource: target => target === undefined ? absent : bindingSources.get(target.sessionId)!,
renderArea: (binding, { empty, children }) => binding.key === undefined
? <>{empty?.() ?? null}</>
: <>{children}</>,
@@ -185,12 +188,11 @@ function makeHost() {
host,
list,
workspaces,
// Driver surface: set(id) publishes the resolved binding (or the absent
// projection) through the scope adapter.
current: {
getSnapshot: () => currentId === undefined ? undefined : targets.get(currentId),
set: (id: string | undefined) => {
currentId = id
currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding)
currentBinding.set(id === undefined ? absentBinding : bindingSources.get(id)?.getSnapshot() ?? absentBinding)
},
},
declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
@@ -217,13 +219,14 @@ function makeHost() {
const session = observable<unknown>(initial)
const binding: ScopedStandardSourceBinding = {
key: id,
ctx: scopeCtx,
ctx: new Context(),
hooks: { session },
keyedHooks: {},
props: { sessionId: id },
}
sessionSources.set(id, session)
bindings.set(id, binding)
targets.set(id, { sessionId: id } as SessionReference)
bindingSources.set(id, observable<StandardSourceBinding>(binding))
if (currentId === id) currentBinding.set(binding)
return binding
},
@@ -276,9 +279,14 @@ const chainEntryOf = (partial: {
})
/** Mount a root entry whose component renders `body` with its kit renderSlotChain. */
function mountChainRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (renderSlotChain: RenderSlotChainFn) => ReactNode) {
function mountChainRoot(
h: Fake,
children: Record<string, DeclaredSpec>,
body: (renderSlotChain: RenderSlotChainFn, Provider: SessionProviderComponent) => ReactNode,
) {
const dispose = h.add('root', {
component: (props: { renderSlotChain: RenderSlotChainFn }) => <>{body(props.renderSlotChain)}</>,
component: (props: { renderSlotChain: RenderSlotChainFn; SessionProvider: SessionProviderComponent }) =>
<>{body(props.renderSlotChain, props.SessionProvider)}</>,
children,
})
const renderer = createSlotRenderer()
@@ -612,11 +620,14 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
h.add('k.chain', chainEntryOf({ component: () => <b>never</b>, select }))
let fallbackOnly = true
const { view } = mountChainRoot(h, { 'k.chain': CHAIN_SESSION },
renderSlotChain => renderSlotChain(
'k.chain',
{},
{ fallback: <input aria-label="resident" />, fallbackOnly, overlay: true },
))
(renderSlotChain, Provider) => {
const content = renderSlotChain(
'k.chain',
{},
{ fallback: <input aria-label="resident" />, fallbackOnly, overlay: true },
)
return <Provider session={h.current.getSnapshot()} empty={() => content}>{content}</Provider>
})
const input = view.getByRole('textbox', { name: 'resident' })
expect(select).not.toHaveBeenCalled()
@@ -803,6 +814,7 @@ describe('standard-kit synthesis', () => {
h.declare('k.session', SINGLE_SESSION)
h.declare('k.single', SINGLE_ROOT)
h.addSession('s1')
h.current.set('s1')
h.add('k.session', { component: ({ sessionId }: { sessionId?: string }) => <b>{sessionId}</b> })
const rootSeen: AnyProps[] = []
// Root entry uses its INJECTED provider seat (no value import of SessionProvider).
@@ -812,7 +824,7 @@ describe('standard-kit synthesis', () => {
const Provider = props['SessionProvider'] as SessionProviderComponent
const renderSlot = props['renderSlot'] as RenderSlotFn
return (
<Provider empty={() => <i>empty</i>}>
<Provider session={h.current.getSnapshot()} empty={() => <i>empty</i>}>
{renderSlot('k.session', {})}
</Provider>
)
@@ -820,8 +832,6 @@ describe('standard-kit synthesis', () => {
children: { 'k.session': SINGLE_SESSION },
})
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('empty')
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1')
// Entries whose children are all root-scope get no provider seat.
@@ -1078,7 +1088,8 @@ describe('session-maybe adoption identity', () => {
return <b>{`${sessionId ?? 'blank'}#${mount}`}</b>
},
})
const { view } = mountRoot(h, { 'k.maybe': SINGLE_MAYBE }, renderSlot => renderSlot('k.maybe', {}))
const { view } = mountRoot(h, { 'k.maybe': SINGLE_MAYBE }, renderSlot =>
renderSlot('k.maybe', {}))
return { view, seen }
}
@@ -1140,9 +1151,11 @@ describe('session-maybe adoption identity', () => {
props: { sessionId: 'replacement' },
}
act(() => {
const source = observable<StandardSourceBinding>(binding)
h.replaceScope({
current: observable(binding),
resolve: key => key === binding.key ? binding : undefined,
current: source,
bindingSource: () => source,
renderArea: (_binding, { children }) => <>{children}</>,
})
})
expect(view.container.textContent).toBe('replacement#1')
@@ -1,17 +1,24 @@
// @vitest-environment jsdom
import { Fragment, useEffect, useRef } from 'react'
import { useState, useSyncExternalStore, type ReactNode } from 'react'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { act, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type { SessionBinding as ControllerBinding, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
SessionProviderComponent, StoredEntry,
SessionProviderComponent, StoredEntry, ScopedStandardSourceBinding,
SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-slots'
import type {
ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import { renderSessionArea } from '../../ui-session/src/client/session-provider.tsx'
import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
type SessionBinding = ScopedStandardSourceBinding
const roots: Context[] = []
afterEach(async () => {
cleanup()
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
vi.restoreAllMocks()
})
function observable<T>(initial: T) {
let value = initial
@@ -23,216 +30,238 @@ function observable<T>(initial: T) {
}
}
/**
* Minimal host: SessionProvider only reads the Session scope adapter, but it must
* render inside the renderer tree (HostContext), so the harness mounts a real
* root entry whose body is the test's render-prop provider.
*/
function makeHost(
bodies: {
root: (
rp: (key: string, owner: object) => React.ReactNode,
SessionProvider: SessionProviderComponent,
) => React.ReactNode
},
options: { installRenderArea?: boolean } = {},
) {
const scopeCtx = new Context()
type SessionBody = (
renderSlot: (key: string, owner: object) => ReactNode,
Provider: SessionProviderComponent,
reference: SessionReference | undefined,
) => ReactNode
function makeHost(body: SessionBody, options: { installRenderArea?: boolean; optional?: boolean } = {}) {
const absentBinding: StandardSourceBinding = {
key: undefined,
hooks: { session: undefined },
keyedHooks: {},
props: { sessionId: undefined },
key: undefined, hooks: { session: undefined }, keyedHooks: {}, props: { sessionId: undefined },
}
const currentBinding = observable<StandardSourceBinding>(absentBinding)
let currentId: string | undefined
const bindings = new Map<string, SessionBinding>()
const absent = observable(absentBinding)
const selected = observable<SessionReference | undefined>(undefined)
const references = new Map<string, SessionReference>()
const sources = new Map<SessionReference, ReturnType<typeof observable<StandardSourceBinding>>>()
const sessionEntries: StoredEntry[] = []
const root = observable<StandardSourceBinding>({
key: undefined,
hooks: {},
keyedHooks: {},
props: {},
const root = observable<StandardSourceBinding>({ key: undefined, hooks: {}, keyedHooks: {}, props: {} })
const bindingSource = vi.fn((reference: SessionReference | undefined) => {
if (reference === undefined) return absent
const source = sources.get(reference)
if (source === undefined) throw new Error('unknown fixture reference')
return source
})
const sessionAdapter: SlotScopeAdapter = {
current: currentBinding,
resolve: key => bindings.get(key),
...(options.installRenderArea === false
? {}
: {
renderArea: (binding, { empty, children }) => binding.key === undefined
? <>{empty?.() ?? null}</>
: <Fragment key={binding.key}>{children}</Fragment>,
}),
current: absent,
bindingSource,
...options.installRenderArea === false ? {} : { renderArea: renderSessionArea },
}
const scope = options.optional === true ? 'session-maybe' : 'session'
const rootEntry: StoredEntry = {
component: (props: {
renderSlot: (key: string, owner: object) => React.ReactNode
renderSlot: (key: string, owner: object) => ReactNode
SessionProvider: SessionProviderComponent
}) => <>{bodies.root(props.renderSlot, props.SessionProvider)}</>,
}) => {
const reference = useSyncExternalStore(selected.subscribe, selected.getSnapshot)
return body(props.renderSlot, props.SessionProvider, reference)
},
options: {},
children: { 'k.session': { kind: 'single', scope: 'session' } },
children: { 'k.session': { kind: 'single', scope } },
}
const host: SlotRendererHost = {
subscribe: () => () => {},
getVersion: () => 0,
subscribe: () => () => {}, getVersion: () => 0,
entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
// Single-kind everywhere and no crashes in this suite: the projection is
// the raw view and crash reports never fire.
entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries,
reportEntryError: () => {},
specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
isLive: () => true,
storeOf: () => undefined,
root,
scopeRevision: observable(0),
scope: () => sessionAdapter,
specOf: key => key === 'k.session' ? { kind: 'single', scope } : undefined,
isLive: () => true, storeOf: () => undefined,
root, scopeRevision: observable(0), scope: () => sessionAdapter,
}
return {
host,
// Driver surface: set(id) publishes the resolved binding (or the absent
// projection) through the scope adapter.
host, bindingSource,
current: {
set: (id: string | undefined) => {
currentId = id
currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding)
},
set: (id: string | undefined) => { selected.set(id === undefined ? undefined : references.get(id)) },
},
addSession: (id: string) => {
// Bare source per binding (identity-stable): the machinery binds useSession from it.
const binding: SessionBinding = {
key: id,
ctx: scopeCtx,
hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
keyedHooks: {},
props: { sessionId: id },
const ctx = new Context()
roots.push(ctx)
const binding: ScopedStandardSourceBinding = {
key: id, ctx,
hooks: { session: observable({ sid: id }) },
keyedHooks: {}, props: { sessionId: id },
}
bindings.set(id, binding)
if (currentId === id) currentBinding.set(binding)
return binding
},
/** Swap one session's binding in place (roster-change stand-in); republish when current. */
replaceSession: (binding: SessionBinding) => {
bindings.set(binding.key, binding)
if (currentId === binding.key) currentBinding.set(binding)
const release = vi.fn()
const reference: SessionReference = {
sessionId: id as SessionId,
binding: { sessionId: id, ctx } as ControllerBinding,
ready: Promise.resolve({ sessionId: id, ctx } as ControllerBinding),
release,
[Symbol.dispose]: release,
}
const source = observable<StandardSourceBinding>(binding)
references.set(id, reference)
sources.set(reference, source)
return { binding, reference, source, release }
},
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
}
}
function Counter({ sessionId }: { sessionId: string }) {
const [count, setCount] = useState(0)
return <button onClick={() => { setCount(count + 1) }}>{sessionId}:{count}</button>
}
describe('SessionProvider', () => {
it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => {
const h = makeHost({
root: (_renderSlot, SessionProvider) => (
<SessionProvider empty={() => <span>empty</span>}>
<div data-testid="body">session</div>
</SessionProvider>
),
})
it('renders explicit absence and switches only when the owner supplies a reference', () => {
const h = makeHost((renderSlot, Provider, reference) =>
<Provider session={reference} empty={() => <span>empty</span>}>{renderSlot('k.session', {})}</Provider>)
h.registerSession({ component: ({ sessionId }: { sessionId: string }) => <b>{sessionId}</b>, options: {} })
h.addSession('s1')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('empty')
expect(h.bindingSource).toHaveBeenCalledWith(undefined)
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('session')
act(() => { h.current.set('ghost') }) // listed nowhere: cell() misses
expect(view.container.textContent).toBe('s1')
act(() => { h.current.set(undefined) })
expect(view.container.textContent).toBe('empty')
})
it('renders null empty state when the empty prop is omitted', () => {
const h = makeHost({
root: (_renderSlot, SessionProvider) => <SessionProvider><b>session</b></SessionProvider>,
})
it('renders nothing for explicit absence when the empty branch is omitted', () => {
const h = makeHost((_renderSlot, Provider) => <Provider session={undefined}><b>session</b></Provider>)
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('')
})
it('remounts the body on session switch (key semantics) but not on unrelated re-renders', () => {
let mounts = 0
function Body({ id }: { id: string }) {
const mounted = useRef(false)
useEffect(() => {
/* v8 ignore next -- strict-mode double-invoke guard, not a branch under test */
if (!mounted.current) { mounted.current = true; mounts += 1 }
}, [])
return <div>{id}</div>
}
const h = makeHost({
root: (renderSlot, SessionProvider) => (
<SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
),
})
h.registerSession({
component: (props: { sessionId?: string }) => <Body id={props.sessionId ?? 'missing'} />,
options: {},
})
it('does not infer a root scope from the main owner selection', () => {
const h = makeHost(renderSlot => renderSlot('k.session', {}), { optional: true })
h.addSession('s1')
h.addSession('s2')
h.registerSession({ component: ({ sessionId }: { sessionId?: string }) => <b>{sessionId ?? 'absent'}</b>, options: {} })
h.current.set('s1')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
const afterS1 = mounts
act(() => { h.current.set('s2') })
expect(mounts).toBe(afterS1 + 1)
const afterS2 = mounts
view.rerender(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(mounts).toBe(afterS2)
expect(view.container.textContent).toBe('absent')
})
it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
const seen: Record<string, unknown>[] = []
const h = makeHost({
root: (renderSlot, SessionProvider) => (
<SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
),
it('preserves a resident optional subtree when absence adopts its first Session', () => {
const h = makeHost((renderSlot, Provider, reference) => {
const body = renderSlot('k.session', {})
return <Provider session={reference} empty={() => body}>{body}</Provider>
}, { optional: true })
h.registerSession({
component: ({ sessionId }: { sessionId?: string }) => <Counter sessionId={sessionId ?? 'blank'} />,
options: {},
})
h.addSession('first')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
const button = view.getByRole('button')
fireEvent.click(button)
expect(button.textContent).toBe('blank:1')
act(() => { h.current.set('first') })
expect(view.getByRole('button')).toBe(button)
expect(button.textContent).toBe('first:1')
})
it('resets an adopted optional subtree for a same-id replacement generation', () => {
const h = makeHost((renderSlot, Provider, reference) => {
const body = renderSlot('k.session', {})
return <Provider session={reference} empty={() => body}>{body}</Provider>
}, { optional: true })
h.registerSession({ component: Counter, options: {} })
h.addSession('same')
h.current.set('same')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
const oldButton = view.getByRole('button')
fireEvent.click(oldButton)
expect(oldButton.textContent).toBe('same:1')
act(() => {
h.addSession('same')
h.current.set('same')
})
expect(view.getByRole('button')).not.toBe(oldButton)
expect(view.getByRole('button').textContent).toBe('same:0')
})
it('resets local state on a Session switch and on a same-id new Context generation', () => {
const h = makeHost((renderSlot, Provider, reference) =>
<Provider session={reference}>{renderSlot('k.session', {})}</Provider>)
h.registerSession({ component: Counter, options: {} })
h.addSession('s1')
h.addSession('s2')
h.registerSession({
component: (props: { useSession?: <S>(sel: (s: { sid: string }) => S) => S; sessionId?: string }) => {
// The bound hook reads the cell's bare source — asserting through it
// proves the machinery wired THIS session's source, not another's.
seen.push({ sessionId: props.sessionId, read: props.useSession!(s => s.sid) })
return null
},
options: {},
})
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)!['read']).toBe('s1')
expect(seen.at(-1)!['sessionId']).toBe('s1')
h.current.set('s1')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
fireEvent.click(view.getByRole('button'))
expect(view.container.textContent).toBe('s1:1')
act(() => { h.current.set('s2') })
expect(seen.at(-1)!['read']).toBe('s2')
expect(seen.at(-1)!['sessionId']).toBe('s2')
expect(view.container.textContent).toBe('s2:0')
fireEvent.click(view.getByRole('button'))
act(() => {
h.addSession('s2')
h.current.set('s2')
})
expect(view.container.textContent).toBe('s2:0')
})
it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
const seen: unknown[] = []
const h = makeHost({
root: (renderSlot, SessionProvider) => (
<SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
),
})
const original = h.addSession('s1')
it('refreshes descriptor props without remounting the live generation', () => {
const h = makeHost((renderSlot, Provider, reference) =>
<Provider session={reference}>{renderSlot('k.session', {})}</Provider>)
const session = h.addSession('s1')
h.registerSession({
component: (props: { feature?: string }) => {
seen.push(props.feature)
return null
},
component: ({ sessionId, feature }: { sessionId: string; feature?: string }) =>
<><Counter sessionId={sessionId} /><b>{feature}</b></>,
options: {},
})
render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
act(() => { h.current.set('s1') })
expect(seen.at(-1)).toBeUndefined()
// A provider-roster change rematerializes the binding; the scope source
// must carry it to already-mounted entries without a selection change.
act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
expect(seen.at(-1)).toBe('now-live')
h.current.set('s1')
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
fireEvent.click(view.getByRole('button'))
act(() => {
session.source.set({ ...session.binding, props: { ...session.binding.props, feature: 'added' } })
})
expect(view.container.textContent).toBe('s1:1added')
act(() => { session.source.set(session.binding) })
expect(view.container.textContent).toBe('s1:1')
})
it('fails loud when the Session scope owner omits its area renderer', () => {
const h = makeHost({ root: () => null }, { installRenderArea: false })
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
it('binds nested Providers independently and restores the outer reference for siblings', () => {
const h = makeHost((renderSlot, Provider, reference) => (
<Provider session={reference}>
{renderSlot('k.session', { position: 'outer-before' })}
<Provider session={nested.reference}>{renderSlot('k.session', { position: 'inner' })}</Provider>
<Provider session={undefined} empty={() => <i>nested-empty</i>}>hidden</Provider>
{renderSlot('k.session', { position: 'outer-after' })}
</Provider>
))
const outer = h.addSession('a')
const nested = h.addSession('b')
h.current.set('a')
h.registerSession({
component: ({ sessionId, position }: { sessionId: string; position: string }) => <b>{position}:{sessionId};</b>,
options: {},
})
const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
expect(view.container.textContent).toBe('outer-before:a;inner:b;nested-emptyouter-after:a;')
view.unmount()
expect(outer.release).not.toHaveBeenCalled()
expect(nested.release).not.toHaveBeenCalled()
})
it('delivers the Provider seat to entries declaring only optional Session children', () => {
const h = makeHost((renderSlot, Provider, reference) =>
<Provider session={reference}>{renderSlot('k.session', {})}</Provider>, { optional: true })
h.addSession('optional')
h.current.set('optional')
h.registerSession({ component: ({ sessionId }: { sessionId?: string }) => <b>{sessionId}</b>, options: {} })
expect(render(<>{createSlotRenderer().renderRoot(h.host, {})}</>).container.textContent).toBe('optional')
})
it('fails loud when the Session adapter omits its area renderer', () => {
const h = makeHost(() => null, { installRenderArea: false })
vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render(<>{createSlotRenderer().renderRoot(h.host, {})}</>))
.toThrow(/does not provide its area renderer/)
spy.mockRestore()
})
})
@@ -35,7 +35,7 @@ function makeHost() {
}
const sessionAdapter: SlotScopeAdapter = {
current: bindingSource,
resolve: () => undefined,
bindingSource: () => bindingSource,
}
const bump = (key: string) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
@@ -37,7 +37,7 @@ async function bench() {
}
const adapter: SlotScopeAdapter = {
current,
resolve: () => undefined,
bindingSource: () => current,
}
slots.installScope('session', adapter)
return { ctx, slots, fiber }
@@ -11,7 +11,10 @@
import { describe, expect, it } from 'vitest'
import { act, render } from '@testing-library/react'
import { Context } from '@deepseek-ai/cordis'
import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import { useSyncExternalStore } from 'react'
import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionProviderComponent, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {
ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
} from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -39,15 +42,19 @@ function makeHost() {
keyedHooks: { projection: undefined },
props: { sessionId: undefined },
}
const currentBinding = observable<StandardSourceBinding>(absentBinding)
const absentBindingSource = observable<StandardSourceBinding>(absentBinding)
const selected = observable<SessionReference | undefined>(undefined)
const bindingSources = new Map<string, ReturnType<typeof observable<StandardSourceBinding>>>()
const cells = new Map<string, ReturnType<typeof observable<unknown>>>()
/** Store-parallel source family: an unseen key snapshots undefined. */
const absent = { getSnapshot: () => undefined, subscribe: () => () => {} }
const sessionEntries: StoredEntry[] = []
const bindings = new Map<string, SessionBinding>()
const rootEntry: StoredEntry = {
component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
<>{props.renderSlot('k.session', {})}</>,
component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode; SessionProvider: SessionProviderComponent }) => {
const reference = useSyncExternalStore(selected.subscribe, selected.getSnapshot)
return <props.SessionProvider session={reference}>{props.renderSlot('k.session', {})}</props.SessionProvider>
},
options: {},
children: { 'k.session': { kind: 'single', scope: 'session' } },
}
@@ -62,6 +69,7 @@ function makeHost() {
props: { sessionId: id },
}
bindings.set(id, value)
bindingSources.set(id, observable<StandardSourceBinding>(value))
return value
}
const root = observable<StandardSourceBinding>({
@@ -71,8 +79,8 @@ function makeHost() {
props: {},
})
const sessionAdapter: SlotScopeAdapter = {
current: currentBinding,
resolve: binding,
current: absentBindingSource,
bindingSource: target => target === undefined ? absentBindingSource : bindingSources.get(target.sessionId)!,
renderArea: (scopeBinding, { empty, children }) => scopeBinding.key === undefined
? <>{empty?.() ?? null}</>
: <>{children}</>,
@@ -98,7 +106,8 @@ function makeHost() {
// The driver publishes the resolved binding or the absent projection.
current: {
set: (id: string | undefined) => {
currentBinding.set(id === undefined ? absentBinding : binding(id))
if (id !== undefined) binding(id)
selected.set(id === undefined ? undefined : { sessionId: id } as SessionReference)
},
},
registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
@@ -4,13 +4,14 @@ import type {
ISessions,
SessionBinding,
SessionListState,
SessionReference,
SessionRetainInfo,
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 { HostObservable, RootStandardSourceContribution } 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,
@@ -24,42 +25,57 @@ interface SessionsBench {
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>>
readonly retainInfo: ReturnType<typeof vi.fn<ISessions['retainInfo']>>
binding(id: SessionId): SessionBinding
select(id: SessionId | undefined): void
reference(binding: SessionBinding): SessionReference
setMainView(id: SessionId, count: number): void
setRetainInfo(id: SessionId, count: number): void
emitStatus(id: SessionId, running: boolean): void
release(id: SessionId): Promise<void>
}
const sessionId = (value: string): SessionId => value as SessionId
function createSessionsBench(_ctx: Context): SessionsBench {
const roots: Context[] = []
function createSessionsBench(ctx: Context): SessionsBench {
roots.push(ctx)
let statusListener: ((id: SessionId, running: boolean) => void) | undefined
ctx.provide('remote', {
$on: (_event: string, listener: (id: SessionId, running: boolean) => void) => {
statusListener = listener
return () => {
if (statusListener === listener) statusListener = undefined
}
},
} as never)
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 retention = new Map<SessionId, ReturnType<typeof createSnapshotStore<SessionRetainInfo>>>()
const retainSource = (id: SessionId): ReturnType<typeof createSnapshotStore<SessionRetainInfo>> => {
let source = retention.get(id)
if (source === undefined) {
source = createSnapshotStore<SessionRetainInfo>({ referenceCount: 0, retainedBy: {} })
retention.set(id, source)
}
return source
}
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 retainInfo = vi.fn<ISessions['retainInfo']>(id => retainSource(id))
const sessions = {
list,
create: createSession,
open: openSession,
clear: clearSession,
binding: resolveBinding,
retainInfo,
} as unknown as ISessions
return {
@@ -67,10 +83,10 @@ function createSessionsBench(_ctx: Context): SessionsBench {
list,
resolveBinding,
createSession,
openSession,
clearSession,
retainInfo,
binding(id) {
const scopeCtx = new Context()
ctx.effect(() => () => scopeCtx.fiber.dispose())
const snapshot = createSnapshotStore<SessionSnapshot>({
sessionId: id,
pendingSubmissions: [],
@@ -117,14 +133,47 @@ function createSessionsBench(_ctx: Context): SessionsBench {
id,
displayTitle: id,
running: false,
retainedBy: {},
blank: false,
updatedAt: 1,
}
})
return binding
},
select(id) {
list.update((draft) => { draft.current = id })
reference(binding) {
let released = false
const release = vi.fn(() => { released = true })
return {
sessionId: binding.sessionId,
ready: Promise.resolve(binding),
get binding() {
if (released || bindings.get(binding.sessionId) !== binding) throw new Error('Session reference is released')
return binding
},
release,
[Symbol.dispose]: release,
}
},
setMainView(id, count) {
this.setRetainInfo(id, count)
list.update((draft) => {
const row = draft.byId[id]
if (row === undefined) throw new Error(`unknown test Session ${id}`)
draft.byId[id] = {
...row,
retainedBy: count === 0 ? {} : { mainView: count },
}
})
},
setRetainInfo(id, count) {
retainSource(id).set({
referenceCount: count,
retainedBy: count === 0 ? {} : { mainView: count },
})
},
emitStatus(id, running) {
if (statusListener === undefined) throw new Error('api-session/status is not subscribed')
statusListener(id, running)
},
async release(id) {
bindings.delete(id)
@@ -140,195 +189,255 @@ function createUiSession(ctx: Context, bench: SessionsBench): UiSession {
return new UiSession(ctx, bench.sessions)
}
afterEach(() => {
afterEach(async () => {
await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
vi.restoreAllMocks()
})
describe('UiSession bindings', () => {
it('binds each materialized Session to renderer-owned Store cleanup', () => {
it('tracks the main-view source and moves its retention watcher between Sessions', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const first = bench.reference(bench.binding(sessionId('s1')))
const second = bench.reference(bench.binding(sessionId('s2')))
const service = createUiSession(ctx, bench)
const firstSource = service.bindingSource(first)
const secondSource = service.bindingSource(second)
const current = service.adapter.current
const changed = vi.fn()
current.subscribe(changed)
expect(current.getSnapshot().key).toBeUndefined()
bench.setMainView(first.sessionId, 1)
expect(current.getSnapshot()).toBe(firstSource.getSnapshot())
const missing = sessionId('missing')
bench.list.update((draft) => { draft.byId[missing] = undefined as never })
expect(current.getSnapshot()).toBe(firstSource.getSnapshot())
bench.list.update((draft) => { Reflect.deleteProperty(draft.byId, missing) })
bench.retainInfo.mockClear()
bench.setRetainInfo(first.sessionId, 2)
expect(bench.retainInfo).toHaveBeenCalledOnce()
expect(current.getSnapshot()).toBe(firstSource.getSnapshot())
bench.setRetainInfo(second.sessionId, 1)
bench.list.update((draft) => {
draft.byId[first.sessionId] = { ...draft.byId[first.sessionId]!, retainedBy: {} }
draft.byId[second.sessionId] = {
...draft.byId[second.sessionId]!,
retainedBy: { mainView: 1 },
}
})
bench.setRetainInfo(first.sessionId, 0)
expect(current.getSnapshot()).toBe(secondSource.getSnapshot())
bench.retainInfo.mockClear()
bench.setRetainInfo(first.sessionId, 1)
expect(bench.retainInfo).not.toHaveBeenCalled()
bench.list.update((draft) => {
draft.byId[second.sessionId] = { ...draft.byId[second.sessionId]!, retainedBy: {} }
})
bench.setRetainInfo(second.sessionId, 0)
expect(current.getSnapshot().key).toBeUndefined()
bench.retainInfo.mockClear()
bench.setRetainInfo(second.sessionId, 1)
expect(bench.retainInfo).not.toHaveBeenCalled()
expect(changed).toHaveBeenCalledTimes(3)
})
it('shares one stable source between references to the same generation', () => {
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 firstRef = bench.reference(binding)
const secondRef = bench.reference(binding)
const source = service.bindingSource(firstRef)
const value = source.getSnapshot()
const materialized = service.adapter.resolve(binding.sessionId)
expect(service.bindingSource(secondRef)).toBe(source)
expect(service.adapter.bindingSource(firstRef)).toBe(source)
expect(source.getSnapshot()).toBe(value)
expect(value.key).toBe(binding.sessionId)
expect(value.hooks.session).toBe(binding.session)
expect(value.props.sessionId).toBe(binding.sessionId)
expect(value.keyedHooks.projection?.('status')).toBe(binding.session.projections.faceOf('status'))
expect(bindStoreScope).toHaveBeenCalledOnce()
expect(bindStoreScope).toHaveBeenCalledWith(materialized)
expect(bindStoreScope).toHaveBeenCalledWith(value)
firstRef.release()
expect(service.bindingSource(secondRef)).toBe(source)
})
it('materializes built-in sources, caches a binding, and publishes selection and release', async () => {
it('keeps explicit absence stable and independent from catalog changes', () => {
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)
const absent = service.bindingSource(undefined)
const changed = vi.fn()
absent.subscribe(changed)
bench.binding(sessionId('s1'))
expect(service.adapter.current.getSnapshot()).toEqual({
expect(service.adapter.bindingSource(undefined)).toBe(absent)
expect(absent.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(changed).not.toHaveBeenCalled()
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', () => {
it('rejects foreign and released references before resolving UI sources', () => {
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 binding = bench.binding(sessionId('s1'))
const reference = bench.reference(binding)
const foreign = createSessionsBench(new Context())
const foreignRef = foreign.reference(foreign.binding(binding.sessionId))
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()
expect(() => service.bindingSource(foreignRef)).toThrow('not active in this Controller')
reference.release()
expect(() => service.bindingSource(reference)).toThrow('released')
})
it('contains a failing current-binding subscriber and continues dispatch', () => {
it('publishes disposal only to the ended generation and rejects its reference', async () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
bench.binding(id)
const firstRef = bench.reference(bench.binding(sessionId('s1')))
const secondRef = bench.reference(bench.binding(sessionId('s2')))
const first = service.bindingSource(firstRef)
const second = service.bindingSource(secondRef)
const firstChanged = vi.fn()
const secondChanged = vi.fn()
first.subscribe(firstChanged)
second.subscribe(secondChanged)
const secondSnapshot = second.getSnapshot()
await bench.release(firstRef.sessionId)
expect(first.getSnapshot()).toBe(service.bindingSource(undefined).getSnapshot())
expect(firstChanged).toHaveBeenCalledOnce()
expect(second.getSnapshot()).toBe(secondSnapshot)
expect(secondChanged).not.toHaveBeenCalled()
expect(() => service.bindingSource(firstRef)).toThrow('released')
})
it('does not let old Context cleanup remove a same-id replacement source', async () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const oldBinding = bench.binding(sessionId('same'))
const oldSource = service.bindingSource(bench.reference(oldBinding))
const replacement = bench.reference(bench.binding(oldBinding.sessionId))
const nextSource = service.bindingSource(replacement)
const nextSnapshot = nextSource.getSnapshot()
const changed = vi.fn()
nextSource.subscribe(changed)
expect(nextSource).not.toBe(oldSource)
expect(oldSource.getSnapshot().key).toBe(oldBinding.sessionId)
await oldBinding.ctx.fiber.dispose()
expect(oldSource.getSnapshot().key).toBeUndefined()
expect(service.bindingSource(replacement)).toBe(nextSource)
expect(nextSource.getSnapshot()).toBe(nextSnapshot)
expect(changed).not.toHaveBeenCalled()
})
it('contains a failing binding subscriber and continues dispatch', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const service = createUiSession(ctx, bench)
const source = service.bindingSource(bench.reference(bench.binding(sessionId('s1'))))
const failure = new Error('subscriber failed')
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
service.adapter.current.subscribe(() => { throw failure })
const off = source.subscribe(() => { throw failure })
const after = vi.fn()
service.adapter.current.subscribe(after)
source.subscribe(after)
bench.select(id)
service.provide({ props: ['extra'], resolve: () => ({ props: { extra: true } }) })
expect(after).toHaveBeenCalledOnce()
expect(report).toHaveBeenCalledWith(
'[ui-session] current binding subscriber failed:',
failure,
)
expect(report).toHaveBeenCalledWith('[ui-session] Session binding subscriber failed:', failure)
off()
})
it('releases cached bindings when the owning Client context stops', async () => {
it('releases cached sources without rematerializing while the Controller binding stays live', async () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const binding = bench.binding(sessionId('s1'))
const reference = bench.reference(binding)
bench.setMainView(binding.sessionId, 1)
const bindStoreScope = vi.fn()
ctx.provide('slots', { bindStoreScope } as never)
let service: UiSession | undefined
const fiber = ctx.plugin({
apply(scope: Context) { service = new UiSession(scope, bench.sessions) },
})
await fiber.await()
if (service === undefined) throw new Error('UiSession did not start')
const source = service.bindingSource(reference)
expect(service.adapter.current.getSnapshot().key).toBe(binding.sessionId)
expect(bindStoreScope).toHaveBeenCalledOnce()
await fiber.dispose()
expect(source.getSnapshot().key).toBeUndefined()
expect(service.bindingSource(reference).getSnapshot().key).toBeUndefined()
expect(bindStoreScope).toHaveBeenCalledOnce()
expect(binding.ctx.fiber.uid).not.toBeNull()
})
it('assembles all descriptor snapshots before notifying independent Session sources', () => {
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 a = bench.reference(bench.binding(sessionId('a')))
const b = bench.reference(bench.binding(sessionId('b')))
const sourceA = service.bindingSource(a)
const sourceB = service.bindingSource(b)
const absent = service.bindingSource(undefined)
expect(sourceA).not.toBe(sourceB)
const values: unknown[] = []
sourceA.subscribe(() => { values.push(sourceB.getSnapshot().props.feature) })
const changedB = vi.fn()
sourceB.subscribe(changedB)
const custom = createSnapshotStore(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 remove = service.provide({
hooks: ['custom'], keyedHooks: ['customKeyed'], props: ['feature'],
resolve: binding => ({
hooks: { custom }, keyedHooks: { customKeyed: keyed }, props: { feature: binding.sessionId },
}),
})
const disposeNeighbor = service.provide({
props: ['neighborProp'],
resolve: () => ({ props: { neighborProp: 'neighbor' } }),
const removeNeighbor = service.provide({
props: ['neighbor'], resolve: () => ({ props: { neighbor: 'kept' } }),
})
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')
expect(service.bindingSource(a)).toBe(sourceA)
expect(service.bindingSource(b)).toBe(sourceB)
expect(sourceA.getSnapshot().props.feature).toBe(a.sessionId)
expect(sourceB.getSnapshot().props.feature).toBe(b.sessionId)
expect(sourceA.getSnapshot().hooks.custom).toBe(custom)
expect(sourceA.getSnapshot().keyedHooks.customKeyed).toBe(keyed)
expect(absent.getSnapshot().hooks).toHaveProperty('custom', undefined)
expect(values).toEqual([b.sessionId, b.sessionId])
remove()
expect(values.at(-1)).toBeUndefined()
expect(sourceA.getSnapshot().props).not.toHaveProperty('feature')
expect(sourceB.getSnapshot().props).not.toHaveProperty('feature')
expect(sourceA.getSnapshot().props.neighbor).toBe('kept')
expect(absent.getSnapshot().hooks).not.toHaveProperty('custom')
remove()
expect(changedB).toHaveBeenCalledTimes(3)
removeNeighbor()
expect(sourceA.getSnapshot().props).not.toHaveProperty('neighbor')
})
it.each([
@@ -339,10 +448,8 @@ describe('UiSession bindings', () => {
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'`)
service.bindingSource(bench.reference(bench.binding(sessionId('s1'))))
expect(() => service.provide(descriptor as never)).toThrow(`uiSession.provide: undeclared ${kind} 'surprise'`)
})
it.each([
@@ -353,26 +460,19 @@ describe('UiSession bindings', () => {
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'`)
service.bindingSource(bench.reference(bench.binding(sessionId('s1'))))
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) } }),
}],
['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}`)
expect(() => service.provide(descriptor)).toThrow(`uiSession.provide: duplicate ${kind}`)
})
it('rejects cross-compartment collisions at the final standard prop name', () => {
@@ -380,29 +480,30 @@ describe('UiSession bindings', () => {
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()
service.provide({ hooks: ['feature'], resolve: () => ({ hooks: { feature: source } }) })
const absent = service.bindingSource(undefined)
const before = absent.getSnapshot()
expect(() => service.provide({
keyedHooks: ['feature'],
resolve: () => ({ keyedHooks: { feature: () => source } }),
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 } }),
props: ['useFeature'], resolve: () => ({ props: { useFeature: true } }),
})).toThrow("uiSession.provide: duplicate prop 'useFeature' at prop 'useFeature'")
expect(service.adapter.current.getSnapshot()).toBe(before)
expect(absent.getSnapshot()).toBe(before)
})
it('releases partially rebuilt bindings when a later Session contribution fails', () => {
it('keeps every source unchanged 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)
const first = service.bindingSource(bench.reference(bench.binding(sessionId('s1'))))
const second = service.bindingSource(bench.reference(bench.binding(sessionId('s2'))))
const beforeFirst = first.getSnapshot()
const beforeSecond = second.getSnapshot()
const changed = vi.fn()
first.subscribe(changed)
second.subscribe(changed)
let calls = 0
expect(() => service.provide({
@@ -414,7 +515,72 @@ describe('UiSession bindings', () => {
},
})).toThrow('second binding failed')
expect(calls).toBe(2)
expect(service.adapter.resolve(sessionId('s1'))?.props).not.toHaveProperty('partial')
expect(first.getSnapshot()).toBe(beforeFirst)
expect(second.getSnapshot()).toBe(beforeSecond)
expect(changed).not.toHaveBeenCalled()
})
})
describe('UiSession status', () => {
it('records non-main completions and lets main-view activity acknowledge them', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const id = sessionId('s1')
bench.binding(id)
const service = createUiSession(ctx, bench)
const changed = vi.fn()
const off = service.sessionStatus.subscribe(changed)
expect(service.sessionStatus.getSnapshot().get(id)).toEqual({
running: false,
pendingInteraction: undefined,
completionUnread: false,
})
bench.list.update((draft) => { draft.byId[id]!.running = true })
expect(service.sessionStatus.getSnapshot().get(id)?.running).toBe(true)
bench.list.update((draft) => { draft.byId[id]!.running = false })
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(true)
bench.setMainView(id, 1)
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
bench.setMainView(id, 0)
bench.emitStatus(id, true)
bench.emitStatus(id, false)
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(true)
bench.setMainView(id, 1)
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
bench.emitStatus(id, true)
bench.emitStatus(id, false)
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
bench.setMainView(id, 0)
bench.emitStatus(id, false)
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
bench.emitStatus(id, true)
bench.emitStatus(id, false)
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(true)
bench.emitStatus(id, true)
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
expect(changed).toHaveBeenCalled()
off()
})
it('records an idle event before the initial catalog baseline and retires absent status later', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const id = sessionId('early')
bench.list.update((draft) => { draft.phase = 'pending' })
const service = createUiSession(ctx, bench)
bench.emitStatus(id, false)
expect(service.sessionStatus.getSnapshot().get(id)).toEqual({
running: false,
pendingInteraction: undefined,
completionUnread: true,
})
bench.list.update((draft) => { draft.phase = 'ready' })
expect(service.sessionStatus.getSnapshot().has(id)).toBe(false)
})
})
@@ -425,7 +591,7 @@ describe('UiSession pending interactions', () => {
const service = createUiSession(ctx, bench)
const id = sessionId('s1')
const listener = vi.fn()
const off = service.pendingInteractions.subscribe(listener)
const off = service.sessionStatus.subscribe(listener)
const registerApproval = service.registerPendingInteraction<SessionPendingInteractionBase>(
() => 0,
)
@@ -444,26 +610,26 @@ describe('UiSession pending interactions', () => {
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)
expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(approval)
const removeDuplicate = registerApproval(duplicate, delegate)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(duplicate)
const removeQuestion = registerQuestion(question, delegate)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question)
expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(question)
const removePlan = registerQuestion(plan, delegate)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(plan)
const removeBackground = registerBackground(background, delegate)
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(plan)
removeBackground()
removeQuestion()
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(plan)
removePlan()
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(duplicate)
removeDuplicate()
expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
expect(service.sessionStatus.getSnapshot().get(id)?.pendingInteraction).toBe(approval)
removeApproval()
removeApproval()
expect(service.pendingInteractions.getSnapshot().has(id)).toBe(false)
expect(service.sessionStatus.getSnapshot().has(id)).toBe(false)
off()
await ctx.fiber.dispose()
})
@@ -483,15 +649,15 @@ describe('UiSession pending interactions', () => {
const failure = new Error('pending subscriber failed')
const report = vi.spyOn(console, 'error').mockImplementation(() => {})
service.pendingInteractions.subscribe(() => { throw failure })
service.sessionStatus.subscribe(() => { throw failure })
const after = vi.fn()
service.pendingInteractions.subscribe(after)
service.sessionStatus.subscribe(after)
remove()
expect(after).toHaveBeenCalledOnce()
expect(report).toHaveBeenCalledWith(
'[ui-session] pending interactions subscriber failed:',
'[ui-session] Session status subscriber failed:',
failure,
)
})
@@ -511,7 +677,7 @@ describe('UiSession pending interactions', () => {
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(service.sessionStatus.getSnapshot()).toEqual(new Map())
expect(disposed).toBe(false)
remove()
remove()
@@ -539,10 +705,14 @@ describe('ui-session apply', () => {
expect(slots.provideRoot).toHaveBeenCalledWith({
hooks: {
sessions: bench.sessions.list,
sessionPendingInteraction: ctx.uiSession.pendingInteractions,
sessionStatus: ctx.uiSession.sessionStatus,
},
keyedHooks: { sessionRetainInfo: expect.any(Function) as unknown },
})
expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter)
const root = slots.provideRoot.mock.calls[0]![0] as RootStandardSourceContribution
expect(root.keyedHooks?.sessionRetainInfo?.(sessionId('s1')))
.toBe(bench.retainInfo(sessionId('s1')))
})
it('keeps the Host loader half inert', () => {
@@ -30,10 +30,13 @@ const t: TriggerContentProps['t'] = key => (en as Record<string, string>)[key] ?
// Global standard kit stubs: none of these components consume the hooks.
const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never
type AttentionSnapshot = Parameters<Parameters<TriggerContentProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<TriggerContentProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const kit = { useSessions: unusedHook, useSessionPendingInteraction, usePanelInfo, useResource, useWorkspaces: unusedHook }
const useSessionStatus: TriggerContentProps['useSessionStatus'] = selector => selector(noAttention)
const kit = {
useSessions: unusedHook, useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook,
}
describe('chrome content', () => {
it('TriggerContent renders the icon with the label in the wide column', () => {
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
import { en, zh } from '../src/client/locales.ts'
@@ -28,16 +30,17 @@ const SEAT_CONTENT: Record<string, string> = {
'settings.close': 'Close',
}
type AttentionSnapshot = Parameters<Parameters<SettingsRootComponentProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<SettingsRootComponentProps['useSessionStatus']>[0]>[0]
type ConnectionSnapshot = Parameters<Parameters<SettingsRootComponentProps['useConnectionState']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: SettingsRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: SettingsRootComponentProps['useSessionStatus'] = selector => selector(noAttention)
function mount({
wide = true,
dictionary = en,
connectionState = 'connected',
onboardingActive = true,
mainView = true,
rows = [
{ id: 'general', order: 0, label: 'General' },
{ id: 'models', order: 10, label: 'Models' },
@@ -52,6 +55,7 @@ function mount({
dictionary?: typeof en | typeof zh
connectionState?: ConnectionSnapshot
onboardingActive?: boolean
mainView?: boolean
rows?: Row[]
steps?: Step[]
} = {}) {
@@ -68,18 +72,26 @@ function mount({
return SEAT_CONTENT[key]
}) as SettingsRootComponentProps['renderSlot'],
)
const useSessions = ((select: (state: unknown) => unknown) => select(onboardingActive
? { phase: 'ready', current: undefined, byId: {} }
: {
phase: 'ready',
current: 'active-session',
byId: { 'active-session': { blank: false } },
})) as never
const activeId = SessionId('active-session')
const sessions: SessionListState = {
ids: [activeId],
byId: {
[activeId]: {
id: activeId,
displayTitle: 'Active',
blank: onboardingActive,
running: false,
retainedBy: mainView ? { mainView: 1 } : {},
updatedAt: 0,
},
},
phase: 'ready', subagentsByParent: {}, jobsBySession: {},
}
const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
const props: SettingsRootComponentProps = {
useSessions,
useSessionPendingInteraction,
usePanelInfo, useResource,
useSessions: select => select(sessions),
useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useWorkspaces: unusedHook,
wide,
reconnect,
@@ -356,6 +368,12 @@ describe('SettingsPanel navigation', () => {
expect(inactive).toHaveLength(0)
})
it('keeps onboarding active before a main Session is retained', () => {
const { renderSlot } = mount({ mainView: false })
expect(renderSlot.mock.calls.some(call => call[0] === 'settings.onboarding')).toBe(true)
})
it('paints no takeover chrome of its own around the mounted step', () => {
// The chrome (mask, opaque stage, #root inert) belongs to the step via
// the step-owned dialog surface — a mounted-but-deciding step that
@@ -45,9 +45,9 @@ const DeepSeekConfig = Schema.object({
})),
})
type AttentionSnapshot = Parameters<Parameters<DeepSeekOnboardingDialogProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<DeepSeekOnboardingDialogProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: DeepSeekOnboardingDialogProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: DeepSeekOnboardingDialogProps['useSessionStatus'] = selector => selector(noAttention)
function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
const value = apiKeyEnv === null ? {} : { apiKeyEnv }
@@ -147,8 +147,8 @@ function harness(options: {
complete,
openSection,
useSessions: unusedHook,
useSessionPendingInteraction,
usePanelInfo, useResource,
useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useWorkspaces: unusedHook,
controller,
useModels: bindSnapshotSelector(controller.store),
@@ -52,9 +52,9 @@ function welcomeView(value: unknown, revision = 0) {
}
}
type AttentionSnapshot = Parameters<Parameters<WelcomeNoticeProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<WelcomeNoticeProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: WelcomeNoticeProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: WelcomeNoticeProps['useSessionStatus'] = selector => selector(noAttention)
function mount(
version?: string,
@@ -93,8 +93,8 @@ function mount(
complete,
openSection: vi.fn(),
useSessions: unusedHook,
useSessionPendingInteraction,
usePanelInfo, useResource,
useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useWorkspaces: unusedHook,
controller,
useWelcome: bindSnapshotSelector(controller.store),
@@ -20,18 +20,16 @@ const t = ((key: ArchivedSessionsLocaleKey, params?: Record<string, string | num
)) as ArchivedSessionsSectionProps['t']
function summary(id: string, title: string, updatedAt: number): SessionSummary {
return { id: sid(id), displayTitle: title, running: false, blank: false, updatedAt }
return { id: sid(id), displayTitle: title, running: false, retainedBy: {}, blank: false, updatedAt }
}
function sessionState(sessions: readonly SessionSummary[], phase: SessionListState['phase'] = 'ready'): SessionListState {
return {
ids: sessions.map(session => session.id),
byId: Object.fromEntries(sessions.map(session => [session.id, session])),
current: undefined,
phase,
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
}
}
@@ -45,6 +45,7 @@ async function boot() {
'conversation.session.header.corner': { kind: 'single', scope: 'session' },
})
await rt.sessions.add({ id: SESSION })
await rt.sessions.retainFor(rt.ctx, SESSION, { source: 'mainView' }).ready
await rt.mount({ inject: [...resourcesInject], apply: resourcesApply })
const read = vi.fn<ClientRemote['workspaceFiles']['read']>().mockImplementation(async (_sessionId, _path, range) => ({
ok: true,
@@ -54,8 +55,7 @@ async function boot() {
ok: true, value: { absolutePath: '/host/notes', version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true },
})
const workspaceFiles = { read, readAll: bytes }
rt.ctx.provide('remote', { workspaceFiles } as never)
rt.ctx.provide('remote.workspaceFiles', workspaceFiles as never)
rt.remote.provideNamespaces({ workspaceFiles })
rt.ctx.effect(() => rt.ctx.resources.register({
protocol: 'file',
open: async function* (_address, { signal }) {
@@ -5,6 +5,7 @@ import { act } from '@testing-library/react'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
import { documentTabInfoFactory } from '../src/client/document/contract.ts'
import { apply, MARKDOWN_BODY_ID, markdownDefinition } from '../src/client/markdown/index.ts'
@@ -42,7 +43,10 @@ describe('Markdown implementation registration', () => {
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
locale.setLocale('en')
await runtime.sessions.add({ id: 'markdown-registration' })
const sessionId = SessionId('markdown-registration')
await runtime.sessions.add({ id: sessionId })
const reference = runtime.sessions.retainFor(runtime.ctx, sessionId)
await reference.ready
const feature = await runtime.mount({ inject: ['slots', 'locale', 'documentPreviews'], apply })
expect(previews.getSnapshot().map(definition => definition.id)).toEqual([MARKDOWN_BODY_ID])
const useTabInfo = vi.fn<UseSidebarRightTabInfo>(() => { throw new Error('Markdown rendering does not need tab actions') })
@@ -50,12 +54,20 @@ describe('Markdown implementation registration', () => {
'sidebar.right.tab.document': {
kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } },
},
}, ({ renderSlot }) => renderSlot('sidebar.right.tab.document', {
resourceAddress: 'dsh-resource://file/session/markdown-registration/notes.md',
content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true },
wrap: false,
scrollportRef: vi.fn(),
}, { entryKey: MARKDOWN_BODY_ID, hookContext: useTabInfo, fallback: <span data-missing-markdown /> }))
}, ({ renderSlot, SessionProvider }) => (
<SessionProvider session={reference}>
{renderSlot('sidebar.right.tab.document', {
resourceAddress: 'dsh-resource://file/session/markdown-registration/notes.md',
content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true },
wrap: false,
scrollportRef: vi.fn(),
}, {
entryKey: MARKDOWN_BODY_ID,
hookContext: useTabInfo,
fallback: <span data-missing-markdown />,
})}
</SessionProvider>
))
const view = runtime.renderRoot()
expect(view.getByRole('heading', { name: 'Notes' })).toBeDefined()
expect(view.getByRole('button', { name: 'Copy' })).toBeDefined()
@@ -72,6 +72,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0) {
'conversation.session.header.corner': { kind: 'single', scope: 'session' },
})
await runtime.sessions.add({ id: SESSION })
let reference = runtime.sessions.retainFor(runtime.ctx, SESSION, { source: 'mainView' })
const feature = await runtime.mount({ inject: [...inject], apply })
const bodies = new Map<string, SidebarRightTabInfo>()
const titles = new Map<string, SidebarRightTabInfo>()
@@ -100,14 +101,22 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0) {
runtime.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'test/text' }, Title)
})
const view = runtime.renderSlot('rightbar', { width: 420, viewportWidth, canShow })
const instance = runtime.storeOf('rightbar.session', SESSION) as ReturnType<ReturnType<typeof createSidebarRightStore>['create']>
const instance = runtime.storeOf('rightbar.session', reference) as ReturnType<ReturnType<typeof createSidebarRightStore>['create']>
const controller = runtime.ctx.sidebarRight
const layout = () => instance.getSnapshot().bySession[SESSION]!.layout
const open = (name = 'a.txt', options?: Parameters<typeof controller.openResource>[1]) => {
act(() => { controller.openResource(`dsh-resource://file/session/s-test/${name}`, options) })
return controller.active()!
}
return { runtime, feature, controller, instance, actions: instance.actions, layout, open, frame, pin, bodies, titles, hooks, view }
const selectSession = (id: SessionId): void => {
const next = runtime.sessions.retainFor(runtime.ctx, id, { source: 'mainView' })
reference.release()
reference = next
}
return {
runtime, feature, controller, instance, actions: instance.actions, layout,
open, selectSession, frame, pin, bodies, titles, hooks, view,
}
}
function element(container: HTMLElement, selector: string): HTMLElement {
@@ -347,6 +356,7 @@ describe('RightbarSeat fullscreen entry', () => {
else if (change === 'push') fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]'))
else if (change === 'session') {
await h.runtime.sessions.add({ id: OTHER })
act(() => { h.selectSession(OTHER) })
act(() => { h.controller.openResource('dsh-resource://file/session/s-other/b.txt') })
} else await h.runtime.dispose()
const openCalls = [...h.frame.openRightbar.mock.calls]
@@ -430,6 +440,7 @@ describe('slot-owned useTabInfo', () => {
await h.runtime.sessions.add({ id: OTHER })
expect(h.instance.getSnapshot()).toBe(stored)
expect(info.tab.signal.aborted).toBe(false)
act(() => { h.selectSession(OTHER) })
act(() => { h.controller.openResource('dsh-resource://file/session/s-other/other.txt', { params: { line: 9 } }) })
const otherTab = h.controller.active()!
expect(otherTab.id).toBe(own.id)
@@ -442,7 +453,6 @@ describe('slot-owned useTabInfo', () => {
act(() => { info.tab.actions.close() })
expect(info.tab.signal.aborted).toBe(true)
expect(otherInfo.tab.signal.aborted).toBe(false)
await h.runtime.sessions.setCurrent(SESSION)
const remaining = h.bodies.get(h.controller.active()!.id)!
expect(remaining.tab.navigation.revision).toBe(1)
await h.feature.dispose()
@@ -13,7 +13,7 @@ afterEach(cleanup)
type CleanupProps = Parameters<typeof TerminalCleanup>[0]
const unusedHook = (): never => { throw new Error('Terminal cleanup does not consume global hooks') }
const standard: Omit<CleanupProps, 'useCloseFailures' | 'retryClose' | 't'> = {
useSessions: unusedHook, useSessionPendingInteraction: unusedHook, usePanelInfo: unusedHook,
useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, usePanelInfo: unusedHook,
useWorkspaces: unusedHook, useResource: unusedHook,
}
@@ -23,9 +23,9 @@ const COLUMN_HEIGHT = 600
const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
/** The shell never reads the global hooks; the props share carries them regardless. */
const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
type AttentionSnapshot = Parameters<Parameters<SidebarRootComponentProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<SidebarRootComponentProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: SidebarRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: SidebarRootComponentProps['useSessionStatus'] = selector => selector(noAttention)
afterEach(() => {
cleanup()
@@ -40,7 +40,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } {
const view = render(
<SidebarRoot
collapsed={false} width={300}
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
@@ -31,9 +31,9 @@ afterEach(() => {
// The shell never reads the global hooks itself, but they ride the standard
// props share; stub them as never-called functions.
const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
type AttentionSnapshot = Parameters<Parameters<SidebarRootComponentProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<SidebarRootComponentProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: SidebarRootComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: SidebarRootComponentProps['useSessionStatus'] = selector => selector(noAttention)
function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
const startSession = vi.fn()
@@ -47,7 +47,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
const root = () => (
<SidebarRoot
collapsed={current.collapsed} width={current.width}
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
startSession={startSession} toggleSidebar={toggleSidebar} t={t}
@@ -113,7 +113,7 @@ describe('SidebarRoot shell', () => {
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4')
const { container } = render(<SidebarRoot
collapsed={false} width={300}
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
@@ -133,7 +133,7 @@ describe('SidebarRoot shell', () => {
for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value)
render(<SidebarRoot
collapsed={false} width={300}
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
@@ -148,7 +148,7 @@ describe('SidebarRoot shell', () => {
it('retains the local-build fallback without complete build metadata', () => {
render(<SidebarRoot
collapsed={false} width={300}
useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
useResource={useResource} useWorkspaces={neverHook}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
@@ -41,9 +41,6 @@ function sessionsWith(sessions: SessionSummary[]) {
subscribe: () => () => {},
},
actionCalls,
openSubagent: (address: SubagentAddress) => {
actionCalls.push({ method: 'openSubagent', args: [address] })
},
refreshSubagents: (parentSessionId: SessionId) => {
actionCalls.push({ method: 'refreshSubagents', args: [parentSessionId] })
return Promise.resolve()
@@ -70,6 +67,11 @@ async function fullBench(sessions: SessionSummary[]) {
const ctx = new Context()
const face = sessionsWith(sessions)
ctx.provide('sessions', face)
ctx.provide('uiWorkspace', {
openSession: (address: SubagentAddress) => {
face.actionCalls.push({ method: 'openSession', args: [address] })
},
} as never)
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await provideSlotFaces(ctx)
@@ -90,7 +92,7 @@ const FAMILY: SessionSummary[] = [
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['sessions', 'slots', 'locale'])
expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale'])
})
it('registers catalog actions and selects read-only subagent composers from session facts', async () => {
@@ -107,7 +109,7 @@ describe('apply', () => {
actions.refresh(sid('parent'))
actions.setCatalogOpen(sid('parent'), true)
expect(face.actionCalls).toEqual([
{ method: 'openSubagent', args: [address] },
{ method: 'openSession', args: [address] },
{ method: 'refreshSubagents', args: [sid('parent')] },
{ method: 'setSubagentCatalogOpen', args: [sid('parent'), true] },
])
@@ -56,14 +56,14 @@ function props(
title: '正在扫描项目文件',
displayTitle: 'worker',
running: true,
retainedBy: {},
blank: false,
updatedAt: Date.now(),
},
},
current: PARENT, phase: 'ready',
phase: 'ready',
subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested },
jobsBySession: {},
currentAddress: undefined,
} satisfies SessionListState
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
return select(state)
@@ -85,6 +85,7 @@ function summary(id: SessionId, updatedAt: number): SessionSummary {
id,
displayTitle: id,
running: false,
retainedBy: {},
blank: false,
updatedAt,
}
@@ -26,7 +26,7 @@ const COPY: Record<string, string> = {
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -36,9 +36,9 @@ function emptyWorkspaces() {
return bindSnapshotSelector(store)
}
type AttentionSnapshot = Parameters<Parameters<AppearanceRowComponentProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<AppearanceRowComponentProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: AppearanceRowComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: AppearanceRowComponentProps['useSessionStatus'] = selector => selector(noAttention)
function mount(preference: ThemePreference = 'system') {
// Real store instance — the sanctioned zero-machinery path for tests.
@@ -47,8 +47,8 @@ function mount(preference: ThemePreference = 'system') {
const setTheme = vi.fn()
const props: AppearanceRowComponentProps = {
useSessions: emptySessions(),
useSessionPendingInteraction,
usePanelInfo, useResource,
useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(store),
actions: store.actions,
@@ -28,7 +28,7 @@ const COPY: Record<string, string> = {
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -38,9 +38,9 @@ function emptyWorkspaces() {
return bindSnapshotSelector(store)
}
type AttentionSnapshot = Parameters<Parameters<FontSizeRowComponentProps['useSessionPendingInteraction']>[0]>[0]
type AttentionSnapshot = Parameters<Parameters<FontSizeRowComponentProps['useSessionStatus']>[0]>[0]
const noAttention: AttentionSnapshot = new Map()
const useSessionPendingInteraction: FontSizeRowComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
const useSessionStatus: FontSizeRowComponentProps['useSessionStatus'] = selector => selector(noAttention)
function mount(fontSize = 14) {
// Real store instance — the sanctioned zero-machinery path for tests.
@@ -49,8 +49,8 @@ function mount(fontSize = 14) {
const setFontSize = vi.fn()
const props: FontSizeRowComponentProps = {
useSessions: emptySessions(),
useSessionPendingInteraction,
usePanelInfo, useResource,
useSessionStatus,
usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(store),
actions: store.actions,
@@ -12,7 +12,7 @@ import {
apply as applyChat, inject as injectChat, type ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
@@ -71,7 +71,7 @@ const LAYOUT_CHILDREN = {
async function bench(nodes: ToolResultNode[]) {
const runtime = await SlotTestRuntime.create()
new TestRemote(runtime.ctx, {
runtime.remote.provideNamespaces({
session: {
openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })),
},
@@ -82,9 +82,8 @@ async function bench(nodes: ToolResultNode[]) {
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
runtime.sessions.open(SID)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
openSession: vi.fn(),
} as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -98,6 +97,7 @@ async function bench(nodes: ToolResultNode[]) {
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
},
})
await runtime.sessions.retainFor(runtime.ctx, SID, { source: 'mainView' }).ready
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
await runtime.mount({ inject: [...injectChat], apply: applyChat })
@@ -7,7 +7,7 @@ import type {
ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { SlotTestRuntime, TestRemote, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import {
@@ -115,13 +115,14 @@ async function bench(snapshot: ChatSnapshot) {
summary: { title: 'S', displayTitle: 'S', cwd: '/w' },
snapshot: { running: snapshot.legacy.runningCalls.length > 0 },
})
await runtime.sessions.retainFor(runtime.ctx, SID, { source: 'mainView' }).ready
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } }))
ctx.provide('layout', layout as never)
const sidebarRight = { openResource: vi.fn<(address: string) => void>() }
ctx.provide('sidebarRight', sidebarRight as never)
ctx.provide('uiWorkspace', {} as never)
new TestRemote(ctx, { session: { openWorkspacePath } })
runtime.remote.provideNamespaces({ session: { openWorkspacePath } })
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
locale.register(CONVERSATION_NS, { zh: conversationZh, en: conversationEn })
@@ -25,12 +25,10 @@ function listStore() {
return createSnapshotStore<SessionListState>({
ids: [SID],
byId: {
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
[SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, retainedBy: {}, blank: false, updatedAt: 0 },
},
current: undefined,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
currentAddress: undefined,
})
}
@@ -200,11 +200,9 @@ describe('chat row diff body', () => {
describe('FileMutationRow diff card', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, retainedBy: {}, blank: false, updatedAt: 0, cwd: '/w/app' } },
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
@@ -204,11 +204,9 @@ describe('GenericToolCard read body', () => {
describe('ReadRow keyed toolview', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, retainedBy: {}, blank: false, updatedAt: 0, cwd: '/w/app' } },
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode): Parameters<typeof ReadRow>[0] => ({
@@ -405,11 +405,9 @@ describe('chat row terminal body', () => {
describe('BashRow terminal card', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, retainedBy: {}, blank: false, updatedAt: 0 } },
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
currentAddress: undefined,
})
const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({
@@ -9,7 +9,7 @@ import {
apply as applyChat, inject as injectChat, type ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, TestRemote, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client'
@@ -66,7 +66,7 @@ const LAYOUT_CHILDREN = {
async function bench(nodes: ToolResultNode[]) {
const runtime = await SlotTestRuntime.create()
const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } }))
new TestRemote(runtime.ctx, { session: { openWorkspacePath } })
runtime.remote.provideNamespaces({ session: { openWorkspacePath } })
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
runtime.ctx.provide('layout', layout)
@@ -75,9 +75,8 @@ async function bench(nodes: ToolResultNode[]) {
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
runtime.sessions.open(SID)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
openSession: vi.fn(),
} as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -91,6 +90,7 @@ async function bench(nodes: ToolResultNode[]) {
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
},
})
await runtime.sessions.retainFor(runtime.ctx, SID, { source: 'mainView' }).ready
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
await runtime.mount({ inject: [...injectChat], apply: applyChat })
@@ -283,7 +283,7 @@ describe('keyed toolview hole through the real machinery', () => {
describe('registrant declaration injection', () => {
it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => {
const runtime = await SlotTestRuntime.create()
new TestRemote(runtime.ctx, {
runtime.remote.provideNamespaces({
session: {
openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })),
},
@@ -294,9 +294,8 @@ describe('registrant declaration injection', () => {
runtime.ctx.provide('uiWorkspace', {
openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
beforeOpen(SID)
runtime.sessions.open(SID)
}),
openSession: (id: SessionId) => { runtime.sessions.open(id) },
openSession: vi.fn(),
} as never)
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
@@ -32,7 +32,7 @@ import type {
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import {
ConversationSession, ConversationSessionHeader,
type ConversationSessionHeaderProps, type ConversationSessionProps,
@@ -172,7 +172,7 @@ function standaloneDuration(): Pick<
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
return bindSnapshotSelector(store)
}
@@ -225,9 +225,10 @@ function standaloneProps(
useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)),
useSessions: emptySessions(),
usePanelInfo, useResource,
useSessionPendingInteraction: bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
useSessionStatus: bindSnapshotSelector(
createSnapshotStore<SessionStatusSnapshot>(new Map()),
),
useSessionRetainInfo: () => undefined,
useWorkspaces: emptyWorkspaces(),
useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot(trajectory))),
useInput: bindSnapshotSelector(input),
@@ -262,6 +263,8 @@ async function bench(snapshot = historySnapshot(NODES)) {
snapshot: { blank: false },
session: { loadOlder },
})
const reference = runtime.sessions.retain(SID)
await reference.ready
const trajectoryStore = createSnapshotStore(snapshot)
const conversationStore = createSnapshotStore<ConversationSnapshot>(conversationSnapshot(snapshot))
const uiConversation = new UiConversation(ctx, runtime.sessions)
@@ -285,10 +288,8 @@ async function bench(snapshot = historySnapshot(NODES)) {
slots.register(
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
// The locale plugin backs the locale-aware view tab label ('locale' in
// inject); its settings scope needs a connection handle and the
// forwarded-event port.
// inject); its settings scope needs a connection handle.
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await runtime.mount({ inject: [...localeInject], apply: localeApply })
const provide = vi.spyOn(ctx.uiSession, 'provide')
@@ -297,7 +298,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
if (sourceDescriptor === undefined) throw new Error('ui-trajectory did not provide its standard source')
return {
runtime, ctx, slots, feature, loadOlder, trajectoryStore, conversationStore,
events, views, sourceDescriptor,
events, views, sourceDescriptor, reference,
}
}
@@ -317,16 +318,15 @@ function isConvViewOwner(owner: object): owner is ConvViewOwner {
/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
function mount(fixture: Awaited<ReturnType<typeof bench>>) {
const { runtime, slots, trajectoryStore, conversationStore } = fixture
const session = runtime.sessions.binding(SID)?.session
if (session === undefined) throw new Error('trajectory fixture session is unavailable')
const { slots, trajectoryStore, conversationStore } = fixture
const session = fixture.reference.binding.session
const useSession = bindSnapshotSelector<SessionSnapshot>(session)
const useTrajectory = bindSnapshotSelector<TrajectorySnapshot>(trajectoryStore)
const useConversation = bindSnapshotSelector<ConversationSnapshot>(conversationStore)
const useChat = bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT))
const useSessions = emptySessions()
const useSessionPendingInteraction = bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
const useSessionStatus = bindSnapshotSelector(
createSnapshotStore<SessionStatusSnapshot>(new Map()),
)
const useWorkspaces = emptyWorkspaces()
const conversation = createConversationStore().create()
@@ -352,7 +352,8 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
useConversationViews,
useSessions,
usePanelInfo, useResource,
useSessionPendingInteraction,
useSessionStatus,
useSessionRetainInfo: () => undefined,
useWorkspaces,
useProjection,
useInput,
@@ -438,15 +439,16 @@ describe('plugin registration', () => {
it('keeps one total standard source for a Session binding', async () => {
const b = await bench()
const binding = b.runtime.sessions.binding(SID)
if (binding === undefined) throw new Error('Trajectory source test Session binding is unavailable')
using reference = b.runtime.sessions.retain(SID)
await reference.ready
const binding = reference.binding
const resolveSource = (owner: SessionBinding): ObservableSnapshot<TrajectorySnapshot> => {
const contribution = b.sourceDescriptor.resolve(owner) as {
hooks: { trajectory: ObservableSnapshot<TrajectorySnapshot> }
}
return contribution.hooks.trajectory
}
const source = b.runtime.ctx.uiSession.adapter.resolve(SID)!.hooks.trajectory as
const source = b.runtime.ctx.uiSession.adapter.bindingSource(reference).getSnapshot().hooks.trajectory as
ObservableSnapshot<TrajectorySnapshot>
expect(resolveSource(binding)).toBe(source)
@@ -467,8 +469,10 @@ describe('plugin registration', () => {
sessionId: SessionId,
) => TrajectoryViewInjected
const first = injectEntry(SID)
await b.runtime.sessions.add({ id: 's2' }, { current: false })
const second = injectEntry('s2' as SessionId)
await b.runtime.sessions.add({ id: 's2' })
using reference = b.runtime.sessions.retain('s2' as SessionId)
await reference.ready
const second = injectEntry(reference.sessionId)
expect(second.hooks.duration).toBe(first.hooks.duration)
first.setActualDuration(true)
@@ -29,7 +29,7 @@ type ConversationState = Parameters<Parameters<QuestionComposerProps['useConvers
type ChatState = Parameters<Parameters<QuestionComposerProps['useChat']>[0]>[0]
type TrajectoryState = Parameters<Parameters<QuestionComposerProps['useTrajectory']>[0]>[0]
type InputState = Parameters<Parameters<QuestionComposerProps['useInput']>[0]>[0]
type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPendingInteraction']>[0]>[0]
type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionStatus']>[0]>[0]
const sessionState: SessionState = {
sessionId: SID,
@@ -49,12 +49,10 @@ const sessionState: SessionState = {
}
const sessionList = {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, blank: false, updatedAt: 0 } },
current: SID,
byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, retainedBy: {}, blank: false, updatedAt: 0 } },
phase: 'ready' as const,
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
}
const attentionState: AttentionState = new Map()
const workspaceState = {
@@ -116,7 +114,8 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
useSession: selector => selector(sessionState),
useSessions: selector => selector(sessionList),
usePanelInfo, useResource,
useSessionPendingInteraction: selector => selector(attentionState),
useSessionStatus: selector => selector(attentionState),
useSessionRetainInfo: () => undefined,
useWorkspaces: selector => selector(workspaceState),
useConversation: selector => selector(conversationState),
useChat: selector => selector(chatState),
@@ -28,7 +28,7 @@ type ConversationState = Parameters<Parameters<QuestionComposerProps['useConvers
type ChatState = Parameters<Parameters<QuestionComposerProps['useChat']>[0]>[0]
type TrajectoryState = Parameters<Parameters<QuestionComposerProps['useTrajectory']>[0]>[0]
type InputState = Parameters<Parameters<QuestionComposerProps['useInput']>[0]>[0]
type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionPendingInteraction']>[0]>[0]
type AttentionState = Parameters<Parameters<QuestionComposerProps['useSessionStatus']>[0]>[0]
const sessionState: SessionState = {
sessionId: SID,
@@ -48,12 +48,10 @@ const sessionState: SessionState = {
}
const sessionList = {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, blank: false, updatedAt: 0 } },
current: SID,
byId: { [SID]: { id: SID, displayTitle: 'Session', running: false, retainedBy: {}, blank: false, updatedAt: 0 } },
phase: 'ready' as const,
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
}
const attentionState: AttentionState = new Map()
const workspaceState = {
@@ -115,7 +113,8 @@ const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> =
useSession: selector => selector(sessionState),
useSessions: selector => selector(sessionList),
usePanelInfo, useResource,
useSessionPendingInteraction: selector => selector(attentionState),
useSessionStatus: selector => selector(attentionState),
useSessionRetainInfo: () => undefined,
useWorkspaces: selector => selector(workspaceState),
useConversation: selector => selector(conversationState),
useChat: selector => selector(chatState),
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { Context, Service } from '@deepseek-ai/cordis'
import { Context } from '@deepseek-ai/cordis'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -19,7 +19,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import {
chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot,
stubSettingsScope, workspaceSnapshot,
stubSettingsScope, TestSessions, workspaceSnapshot,
} from '@deepseek-ai/dsh-client-test-runtime'
import {
WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps,
@@ -295,18 +295,16 @@ const listState = (overrides: Partial<SessionListState> = {}): SessionListState
ids: [PARENT_ID, CHILD_ID],
byId: {
[PARENT_ID]: {
id: PARENT_ID, displayTitle: 'parent', running: true, blank: false, updatedAt: 0,
id: PARENT_ID, displayTitle: 'parent', running: true, retainedBy: {}, blank: false, updatedAt: 0,
},
[CHILD_ID]: {
id: CHILD_ID, displayTitle: 'child', parentId: PARENT_ID, origin: 'subagent',
running: true, blank: false, updatedAt: 0,
running: true, retainedBy: {}, blank: false, updatedAt: 0,
},
},
current: PARENT_ID,
phase: 'ready',
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
...overrides,
})
@@ -316,7 +314,8 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
sessionId: PARENT_ID,
useSessions: selector => selector(sessions),
usePanelInfo, useResource,
useSessionPendingInteraction: selector => selector(panelAttention),
useSessionStatus: selector => selector(panelAttention),
useSessionRetainInfo: () => undefined,
useSession: selector => selector(panelSession),
useProjection: () => undefined,
useConversation: selector => selector(panelConversation),
@@ -832,7 +831,11 @@ describe('WorkflowRunPanel', () => {
const openSession = vi.fn()
render(<WorkflowRunPanel {...panelProps(data, listState(), openSession)} />)
fireEvent.click(screen.getByRole('button', { name: '打开 worker' }))
expect(openSession).toHaveBeenCalledWith('child-1')
expect(openSession).toHaveBeenCalledWith({
parentSessionId: PARENT_ID,
childSessionId: CHILD_ID,
mode: 'one-shot',
})
})
it('promotes a running member when its ordinary Session row arrives', () => {
@@ -875,12 +878,6 @@ describe('WorkflowRunPanel', () => {
})
})
class TestSessions extends Service {
readonly opened: SessionId[] = []
constructor(ctx: Context) { super(ctx, 'sessions') }
open(id: SessionId): void { this.opened.push(id) }
}
describe('plugin lifecycle', () => {
it('registers and removes the Definition and keyed renderer with its fiber', async () => {
const ctx = new Context()
@@ -888,7 +885,10 @@ describe('plugin lifecycle', () => {
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin(TestSessions).await()
const sessions = new TestSessions(async (action) => { await action() }, ctx)
ctx.provide('sessions', sessions)
const openSession = vi.fn(async () => {})
ctx.provide('uiWorkspace', { openSession } as never)
const conversationEvents = new UiConversation(ctx, ctx.sessions).events
ctx.slots.register({
name: 'root',
@@ -902,7 +902,7 @@ describe('plugin lifecycle', () => {
const entry = ctx.slots.entries('conversation.chat.node')[0]!
const face = entry.inject?.() as unknown as WorkflowRunInjected
face.openSession(CHILD_ID)
expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID])
expect(openSession).toHaveBeenCalledExactlyOnceWith(CHILD_ID)
await fiber.dispose()
expect(conversationEvents.entries()).toEqual([])
expect(ctx.slots.entries('conversation.chat.node')).toEqual([])
@@ -8,6 +8,7 @@ import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepsee
import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
import { apply as hostApply } from '../src/index.ts'
import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
async function bench() {
const ctx = new Context()
@@ -18,8 +19,6 @@ async function bench() {
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const rename = vi.fn(async () => ({}))
const open = vi.fn()
const clear = vi.fn()
const selectPanel = vi.fn()
ctx.provide('layout', { selectPanel, beginNavigation: () => new AbortController().signal })
const search = vi.fn(async () => ({
@@ -27,7 +26,23 @@ async function bench() {
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
}))
const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
const binding = vi.fn(() => ({ session: { rename: renameSession } }))
const binding = vi.fn((_id: string) => ({ session: { rename: renameSession } }))
const retain = vi.fn((target: string) => {
const resolved = binding(target)
const release = vi.fn()
return {
sessionId: target,
binding: resolved,
ready: Promise.resolve(resolved),
release,
[Symbol.dispose]: release,
} as unknown as SessionReference
})
const using = vi.fn(async (
target: string,
_options: unknown,
operation: (reference: SessionReference) => unknown,
) => await operation(retain(target)))
const fork = vi.fn(async () => 'forked' as never)
const subscribe = () => () => {}
ctx.provide('workspaces', {
@@ -53,11 +68,13 @@ async function bench() {
subscribe,
},
create: vi.fn(async () => 'created' as never),
open,
clear,
retain,
using,
search,
searchResultLimit: 20,
binding,
subagentAddress: vi.fn(() => undefined),
refreshSubagents: vi.fn(() => Promise.resolve()),
fork,
} as never)
const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' }))
@@ -72,7 +89,7 @@ async function bench() {
ctx.provide('locale', locale)
return {
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename,
open, clear, selectPanel, search, renameSession, binding, fork, pickDirectory,
retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory,
}
}
@@ -126,7 +143,7 @@ describe('ui-workspace apply', () => {
browser.startSession()
expect(startSession).toHaveBeenLastCalledWith(undefined)
browser.open('session' as never)
expect(b.open).toHaveBeenCalledWith('session')
expect(b.retain).toHaveBeenCalledWith('session', { source: 'mainView' })
const signal = new AbortController().signal
await expect(browser.searchSessions('match', signal)).resolves.toEqual({
items: [{ sessionId: 'session', snippet: 'match' }],
@@ -135,11 +152,13 @@ describe('ui-workspace apply', () => {
expect(b.search).toHaveBeenCalledWith('match', signal)
expect(browser.searchResultLimit).toBe(20)
await browser.renameSession('session' as never, 'renamed session')
expect(b.binding).toHaveBeenCalledWith('session')
expect(b.using).toHaveBeenCalledWith(
'session', { source: 'workspaceOperation' }, expect.any(Function),
)
expect(b.renameSession).toHaveBeenCalledWith('renamed session')
browser.forkSession('session' as never)
await vi.waitFor(() => {
expect(b.open).toHaveBeenCalledWith('forked')
expect(b.retain).toHaveBeenCalledWith('forked', { source: 'mainView' })
})
expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true })
await browser.renameWorkspace('ws' as never, 'renamed')
@@ -10,7 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, screen } from '@testing-library/react'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
@@ -31,9 +31,8 @@ async function bench() {
runtime.ctx.provide('layout', { selectPanel: vi.fn() })
runtime.releaseWorkspaceSource()
const directoryPicker = {}
const remote = new TestRemote(runtime.ctx)
Object.assign(remote, { directoryPicker })
runtime.ctx.provide('remote.directoryPicker', directoryPicker as never)
const { remote } = runtime
remote.provideNamespaces({ directoryPicker })
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
@@ -17,7 +17,7 @@ import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { RemoteError, SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { RemoteError, SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
@@ -38,8 +38,7 @@ async function createRuntime(): Promise<SlotTestRuntime> {
// The rename flow never picks a directory; the namespace only has to be there
// for ui-workspace's inject to settle.
const directoryPicker = {}
Object.assign(new TestRemote(runtime.ctx), { directoryPicker })
runtime.ctx.provide('remote.directoryPicker', directoryPicker as never)
runtime.remote.provideNamespaces({ directoryPicker })
const locale = new LocaleRuntime(runtime.ctx)
runtime.ctx.provide('locale', locale)
runtime.slots.installLocale(locale)
@@ -63,6 +62,7 @@ describe('session rename through the assembled browser', () => {
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
session: { rename },
})
await runtime.sessions.retainFor(runtime.ctx, SID, { source: 'mainView' }).ready
await runtime.workspaces.update((draft) => {
draft.items = [{
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
@@ -110,6 +110,7 @@ describe('session rename through the assembled browser', () => {
summary: { title: '旧标题', displayTitle: '旧标题', cwd: '/w/alpha' },
session: { rename },
})
await runtime.sessions.retainFor(runtime.ctx, SID, { source: 'mainView' }).ready
await runtime.workspaces.update((draft) => {
draft.items = [{
workspaceId: 'w1' as WorkspaceId, title: 'alpha', path: '/w/alpha',
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-session/client'
import type {
SessionPendingInteraction, SessionStatus, SessionStatusSnapshot,
} from '@deepseek-ai/dsh-client-ui-session/client'
import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
@@ -14,13 +16,19 @@ const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, blank: false,
updatedAt, ...(cwd === undefined ? {} : { cwd }),
updatedAt, ...(cwd === undefined ? {} : { cwd }), retainedBy: {},
})
const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
phase: 'ready', subagentsByParent: {}, jobsBySession: {},
})
const withMain = (state: SessionListState, id: SessionId): SessionListState => ({
...state,
byId: {
...state.byId,
[id]: { ...state.byId[id]!, retainedBy: { ...state.byId[id]!.retainedBy, mainView: 1 } },
},
})
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title,
@@ -31,7 +39,11 @@ const view = (expandedGroups: readonly string[] = [], ungroupedOrder?: readonly
...(ungroupedOrder === undefined ? {} : { ungroupedOrder }),
})
const noArchive: readonly SessionId[] = []
const noAttention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map()
const noAttention: SessionStatusSnapshot = new Map()
const status = (
pendingInteraction: SessionPendingInteraction | undefined,
overrides: Partial<SessionStatus> = {},
): SessionStatus => ({ running: undefined, pendingInteraction, completionUnread: false, ...overrides })
const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
const schedule = (id: string, scheduledAt: string): ScheduleRecord => ({
id: id as ScheduleId,
@@ -88,9 +100,9 @@ describe('deriveGroups', () => {
it('projects pending-interaction state into grouped and flat rows', () => {
const awaiting = { ...summary('awaiting', 10), running: true }
const sessions = list(awaiting)
const attention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map([[
const attention: SessionStatusSnapshot = new Map([[
awaiting.id,
{ key: 'question:1', kind: 'plan-review', sessionId: awaiting.id },
status({ key: 'question:1', kind: 'plan-review', sessionId: awaiting.id } as SessionPendingInteraction, { running: true }),
]])
const grouped = deriveGroups(
sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']),
@@ -104,9 +116,9 @@ describe('deriveGroups', () => {
'projects the %s pending-interaction kind',
(kind) => {
const awaiting = summary(kind, 10)
const attention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map([[
const attention: SessionStatusSnapshot = new Map([[
awaiting.id,
{ key: `${kind}:1`, kind, sessionId: awaiting.id },
status({ key: `${kind}:1`, kind, sessionId: awaiting.id } as SessionPendingInteraction),
]])
expect(deriveFlat(list(awaiting), [awaiting.id], attention)[0]?.pendingInteraction).toBe(kind)
@@ -137,13 +149,10 @@ describe('deriveGroups', () => {
})
it('shows only the current blank session in its Workspace count and tree', () => {
const currentBlank = { ...summary('current-blank', 5), blank: true }
const currentBlank = { ...summary('current-blank', 5), blank: true, retainedBy: { mainView: 1 } }
const staleBlank = { ...summary('stale-blank', 4), blank: true }
const real = summary('shown', 3)
const sessions = {
...list(real, currentBlank, staleBlank),
current: currentBlank.id,
}
const sessions = list(real, currentBlank, staleBlank)
const groups = deriveGroups(
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])],
noArchive, noAttention, view(['first']),
@@ -165,21 +174,22 @@ describe('deriveGroups', () => {
})
it('projects the completion reminder into session and search rows (absent = false)', () => {
const done = { ...summary('done', 3), completed: true }
const done = summary('done', 3)
const plain = summary('plain', 2)
const sessions = list(done, plain)
const statuses: SessionStatusSnapshot = new Map([[done.id, status(undefined, { completionUnread: true })]])
const groups = deriveGroups(
sessions, [workspace('first', ['done', 'plain'])], noArchive, noAttention, view(['first']),
sessions, [workspace('first', ['done', 'plain'])], noArchive, statuses, view(['first']),
)
const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
expect(doneNode.completed).toBe(true)
expect(plainNode.completed).toBe(false)
expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), statuses)
.find(node => node.id === done.id)!.completed).toBe(true)
const search = deriveSearchResults(
sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive,
noAttention, { items: [], hasMore: false }, 10,
statuses, { items: [], hasMore: false }, 10,
)
expect(search.items[0]?.completed).toBe(true)
})
@@ -313,11 +323,11 @@ describe('deriveGroups', () => {
const loose = summary('loose', 2)
const ws = workspace('project', ['owned'])
const ownedGroups = deriveGroups(
{ ...list(owned, loose), current: owned.id }, [ws], noArchive, noAttention, view(),
withMain(list(owned, loose), owned.id), [ws], noArchive, noAttention, view(),
)
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
const looseGroups = deriveGroups(
{ ...list(owned, loose), current: loose.id }, [ws], noArchive, noAttention, view(),
withMain(list(owned, loose), loose.id), [ws], noArchive, noAttention, view(),
)
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
})
@@ -337,7 +347,7 @@ describe('deriveFlat', () => {
const parent = summary('parent', 1)
const fork = { ...summary('fork', 2), parentId: parent.id }
const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const }
const ids = visibleSessionIds({ ...list(parent, fork, subagent), current: subagent.id }, noArchive)
const ids = visibleSessionIds(withMain(list(parent, fork, subagent), subagent.id), noArchive)
expect(ids).toEqual([parent.id, fork.id])
})
@@ -347,12 +357,9 @@ describe('deriveFlat', () => {
})
it('shows only the current blank session and excludes blanks from search', () => {
const currentBlank = { ...summary('current-blank', 9), blank: true }
const currentBlank = { ...summary('current-blank', 9), blank: true, retainedBy: { mainView: 1 } }
const staleBlank = { ...summary('stale-blank', 8), blank: true }
const sessions = {
...list(currentBlank, summary('real', 1), staleBlank),
current: currentBlank.id,
}
const sessions = list(currentBlank, summary('real', 1), staleBlank)
const rows = deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
expect(rows.map(row => row.title)).toEqual(['', 'real'])
@@ -402,9 +409,9 @@ describe('deriveSearchResults', () => {
],
' NEEDLE ',
noArchive,
new Map([[titleHit.id, {
new Map([[titleHit.id, status({
key: 'question:1', kind: 'plan-review', sessionId: titleHit.id,
}]]),
} as SessionPendingInteraction)]]),
{
items: [
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
@@ -457,10 +464,7 @@ describe('deriveSearchResults', () => {
it('excludes blank sessions from search regardless of query or content hits', () => {
const currentBlank = { ...summary('opaque-current', 5), blank: true }
const staleBlank = { ...summary('new session stale', 4), blank: true }
const sessions = {
...list(currentBlank, staleBlank),
current: currentBlank.id,
}
const sessions = list(currentBlank, staleBlank)
// Blank placeholders never match — not their localized-display title, not
// their id, and not even a backend content hit naming them.
const result = deriveSearchResults(
@@ -7,7 +7,7 @@ import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-sess
import type {
WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -38,16 +38,28 @@ const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
retainedBy: overrides.retainedBy ?? {},
})
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
currentAddress: undefined,
...overrides,
})
const sessionState = (
items: readonly SessionSummary[],
overrides: Partial<SessionListState> & { main?: SessionId } = {},
): SessionListState => {
const { main, ...stateOverrides } = overrides
const state: SessionListState = {
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
...stateOverrides,
}
if (main === undefined) return state
const row = state.byId[main]
if (row === undefined) return state
return {
...state,
byId: { ...state.byId, [main]: { ...row, retainedBy: { ...row.retainedBy, mainView: 1 } } },
}
}
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
@@ -56,7 +68,7 @@ const workspaceState = (
items: readonly WorkspaceView[],
archivedSessionIds: readonly SessionId[] = [],
): WorkspaceSnapshot => ({ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null })
const noPendingInteraction: SessionPendingInteractionSnapshot = new Map()
const noPendingInteraction: SessionStatusSnapshot = new Map()
function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
@@ -79,7 +91,8 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
wide: true,
expandSidebar: vi.fn(),
useSessions: hook(sessionState([])),
useSessionPendingInteraction: hook(noPendingInteraction),
useSessionStatus: hook(noPendingInteraction),
useSessionRetainInfo: () => undefined,
usePanelInfo, useResource,
useWorkspaces: hook(workspaceState([])),
useStore: bindSnapshotSelector(store),
@@ -197,7 +210,7 @@ describe('WorkspaceBrowser', () => {
const b = mount({
useSessions: hook(sessionState([
summary('tie-b', 100), summary('blank', 1, { blank: true }), summary('tie-a', 100),
], { current: sid('blank') })),
], { main: sid('blank') })),
useWorkspaces: hook(workspaceState([workspace('alpha', ['missing', 'tie-b', 'blank', 'tie-a'])])),
})
const rows = () => screen.getAllByRole('treeitem').filter(row => row.getAttribute('aria-expanded') === null)
@@ -207,7 +220,7 @@ describe('WorkspaceBrowser', () => {
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
rerender(b, { useSessions: hook(sessionState([
summary('tie-b', 100), summary('blank', 1), summary('tie-a', 100),
], { current: sid('blank') })) })
], { main: sid('blank') })) })
expect(rows().map(row => row.textContent)).toEqual([
expect.stringContaining('tie-a'), expect.stringContaining('tie-b'), expect.stringContaining('blank'),
])
@@ -218,7 +231,7 @@ describe('WorkspaceBrowser', () => {
const panelInfo = { activePanelId: 'panel-a' as MainPanelId }
const b = mount({
usePanelInfo: hook(panelInfo),
useSessions: hook(sessionState([summary('current', 1)], { current: sid('current') })),
useSessions: hook(sessionState([summary('current', 1)], { main: sid('current') })),
useWorkspaces: hook(workspaceState([workspace('alpha', ['current'])])),
})
fireEvent.click(screen.getByText('alpha'))
@@ -298,20 +311,20 @@ describe('WorkspaceBrowser', () => {
.filter(row => row.getAttribute('aria-expanded') === null)
.map(row => row.textContent)
rerender(b, {
useSessions: hook(sessionState([old, blank], { current: blank.id })),
useSessions: hook(sessionState([old, blank], { main: blank.id })),
useWorkspaces: hook({ ...groups(['old', 'blank']), state: 'loading' }),
})
expect(names()).toEqual([expect.stringContaining('新会话'), expect.stringContaining('old')])
expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
rerender(b, {
useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { main: blank.id })),
})
expect(names()).toEqual([expect.stringContaining('blank'), expect.stringContaining('old')])
expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
rerender(b, {
useSessions: hook(sessionState([old, absent, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
useSessions: hook(sessionState([old, absent, { ...blank, blank: false, updatedAt: 20 }], { main: blank.id })),
useWorkspaces: hook(groups(['old', 'absent', 'blank'])),
})
expect(names()).toEqual([
@@ -319,7 +332,7 @@ describe('WorkspaceBrowser', () => {
])
expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
rerender(b, {
useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { main: blank.id })),
useWorkspaces: hook(groups(['old', 'blank'])),
})
expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old'])
@@ -338,14 +351,14 @@ describe('WorkspaceBrowser', () => {
})
rerender(b, {
useSessions: hook(sessionState([old, blank], { current: blank.id })),
useSessions: hook(sessionState([old, blank], { main: blank.id })),
})
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old'])
})
rerender(b, {
useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { main: blank.id })),
})
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old'])
@@ -547,7 +560,7 @@ describe('WorkspaceBrowser', () => {
const ordinary = Array.from({ length: 6 }, (_, index) => summary(`session-${index + 1}`, 6 - index))
const blank = summary('blank', 7, { blank: true })
const b = mount({
useSessions: hook(sessionState([blank, ...ordinary], { current: blank.id })),
useSessions: hook(sessionState([blank, ...ordinary], { main: blank.id })),
useWorkspaces: hook(workspaceState([workspace('alpha', [blank.id, ...ordinary.map(item => item.id)])])),
})
expect(screen.getByText('新会话')).toBeTruthy()
@@ -561,7 +574,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('session-6')).toBeNull()
rerender(b, {
useSessions: hook(sessionState([{ ...blank, blank: false }, ...ordinary], { current: blank.id })),
useSessions: hook(sessionState([{ ...blank, blank: false }, ...ordinary], { main: blank.id })),
})
expect(screen.getByText('blank')).toBeTruthy()
expect(screen.queryByText('session-5')).toBeNull()
@@ -572,7 +585,7 @@ describe('WorkspaceBrowser', () => {
const ordinary = Array.from({ length: 6 }, (_, index) => summary(`session-${index + 1}`, 6 - index))
const blank = summary('blank', 7, { blank: true })
const b = mount({
useSessions: hook(sessionState([blank, ...ordinary], { current: blank.id })),
useSessions: hook(sessionState([blank, ...ordinary], { main: blank.id })),
useWorkspaces: hook(workspaceState([workspace('alpha', [blank.id, ...ordinary.map(item => item.id)])])),
})
await waitFor(() => {
@@ -734,7 +747,7 @@ describe('WorkspaceBrowser', () => {
it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => {
const startSession = vi.fn()
mount({
useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })),
useSessions: hook(sessionState([summary('loose', 1)], { main: sid('loose') })),
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
startSession,
})
@@ -746,7 +759,7 @@ describe('WorkspaceBrowser', () => {
})
it('keeps an already-expanded group when the selection moves within it', () => {
const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') })
const first = sessionState([summary('a', 2), summary('b', 1)], { main: sid('a') })
const b = mount({
useSessions: hook(first),
useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])),
@@ -754,7 +767,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.getByText('a')).toBeTruthy()
// Selection hop inside the same group: the effect re-runs and leaves the
// expansion list unchanged (no duplicate key, group still open).
rerender(b, { useSessions: hook({ ...first, current: sid('b') }) })
rerender(b, { useSessions: hook({ ...first, main: sid('b') }) })
expect(screen.getByText('b')).toBeTruthy()
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('b')).toBeNull()
@@ -765,7 +778,7 @@ describe('WorkspaceBrowser', () => {
const staleBlank = summary('beta-blank', 8, { blank: true })
const sessions = sessionState(
[currentBlank, staleBlank],
{ current: currentBlank.id },
{ main: currentBlank.id },
)
const b = mount({
useSessions: hook(sessions),
@@ -777,7 +790,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('alpha-blank')).toBeNull()
expect(screen.queryByText('beta-blank')).toBeNull()
rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) })
rerender(b, { useSessions: hook({ ...sessions, main: staleBlank.id }) })
expect(screen.getAllByText('新会话')).toHaveLength(1)
b.store.actions.setGroupBy('flat')
rerender(b, {})
@@ -812,7 +825,7 @@ describe('WorkspaceBrowser', () => {
rerender(b, {
useSessions: hook(sessionState([
summary('old', 100), summary('mid', 200), summary('blank', 1, { blank: true }),
], { current: sid('blank') })),
], { main: sid('blank') })),
useWorkspaces: groups(['old', 'mid', 'blank']),
})
expect(names()).toEqual(['新会话', 'mid', 'old'])
@@ -834,7 +847,7 @@ describe('WorkspaceBrowser', () => {
expect(names()).toEqual(['新会话', 'mid', 'old'])
rerender(restored, { useSessions: hook(sessionState([
summary('old', 100), summary('mid', 200), summary('blank', 300),
], { current: sid('blank') })) })
], { main: sid('blank') })) })
expect(names()).toEqual(['blank', 'mid', 'old'])
const ordinaryBlank = screen.getByText('blank').closest('[role="treeitem"]') as HTMLElement
expect(ordinaryBlank.draggable).toBe(true)
@@ -846,7 +859,7 @@ describe('WorkspaceBrowser', () => {
rerender(restored, {
useSessions: hook(sessionState([
summary('old', 100), summary('mid', 200), summary('blank', 300), summary('new-blank', 1, { blank: true }),
], { current: sid('new-blank') })),
], { main: sid('new-blank') })),
useWorkspaces: groups(['old', 'mid', 'blank', 'new-blank']),
})
expect(names()).toEqual(['新会话', 'mid', 'old', 'blank'])
@@ -1792,7 +1805,7 @@ describe('Workspace tree grouping', () => {
const b = mount({ useWorkspaces: hook(workspaceState([root, team, child])) })
fireEvent.click(screen.getByText('Projects'))
rerender(b, {
useSessions: hook(sessionState([summary('child-session', 1)], { current: sid('child-session') })),
useSessions: hook(sessionState([summary('child-session', 1)], { main: sid('child-session') })),
})
expect(screen.queryByText('Child')).toBeNull()
expect(b.store.getSnapshot().groupExpansion.root).toBe(false)
@@ -9,7 +9,7 @@ import type {
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
import { zh } from '../src/client/locales.ts'
@@ -35,9 +35,9 @@ function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {},
}
const noPendingInteraction: SessionPendingInteractionSnapshot = new Map()
const noPendingInteraction: SessionStatusSnapshot = new Map()
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})
@@ -98,7 +98,8 @@ function mount(
open
anchorRef={anchorRef}
useSessions={hook(sessions)}
useSessionPendingInteraction={hook(noPendingInteraction)}
useSessionStatus={hook(noPendingInteraction)}
useSessionRetainInfo={() => undefined}
usePanelInfo={usePanelInfo} useResource={useResource}
useWorkspaces={hook(workspaceState(nextItems))}
onPick={onPick}
@@ -220,7 +221,8 @@ describe('WorkspacePicker', () => {
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([workspace('alpha', 'Alpha')]))}
useSessionPendingInteraction={hook(noPendingInteraction)}
useSessionStatus={hook(noPendingInteraction)}
useSessionRetainInfo={() => undefined}
usePanelInfo={usePanelInfo} useResource={useResource}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
@@ -237,7 +239,8 @@ describe('WorkspacePicker', () => {
render(
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
useSessionPendingInteraction={hook(noPendingInteraction)}
useSessionStatus={hook(noPendingInteraction)}
useSessionRetainInfo={() => undefined}
usePanelInfo={usePanelInfo} useResource={useResource}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
@@ -1,8 +1,9 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
ISessions, SessionListState, SessionSummary,
ISessions, SessionListState, SessionReference, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type {
IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
@@ -17,10 +18,27 @@ import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigati
const sid = (id: string): SessionId => SessionId(id)
const wid = (id: string): WorkspaceId => id as WorkspaceId
afterEach(() => {
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
function persistSelection(selection: {
readonly sessionId?: SessionId
readonly subagentAddress?: SubagentAddress
}): Map<string, string> {
const backing = new Map([['dsh.sessions.current', JSON.stringify(selection)]])
vi.stubGlobal('localStorage', {
getItem: (key: string) => backing.get(key) ?? null,
setItem: (key: string, value: string) => { backing.set(key, value) },
removeItem: (key: string) => { backing.delete(key) },
})
return backing
}
function workspace(
id: string,
sessionIds: readonly SessionId[] = [],
@@ -44,22 +62,20 @@ function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSu
blank: false,
updatedAt: 0,
...overrides,
retainedBy: overrides.retainedBy ?? {},
}
}
function sessionState(
summaries: readonly SessionSummary[] = [],
current?: SessionId,
phase: SessionListState['phase'] = 'ready',
): SessionListState {
return {
ids: summaries.map(item => item.id),
byId: Object.fromEntries(summaries.map(item => [item.id, item])),
current,
phase,
subagentsByParent: {},
jobsBySession: {},
currentAddress: undefined,
}
}
@@ -105,23 +121,47 @@ class MutableSource<T> {
}
}
class FakeSessions {
interface RetainedSession {
readonly reference: SessionReference
readonly release: ReturnType<typeof vi.fn<() => void>>
}
class FakeSessions implements ISessions {
readonly list: MutableSource<SessionListState>
readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
readonly clear: ReturnType<typeof vi.fn<() => void>>
readonly fork = vi.fn<ISessions['fork']>(async () => sid('forked'))
readonly retained: RetainedSession[] = []
readonly refreshSubagents = vi.fn<ISessions['refreshSubagents']>(() => Promise.resolve())
readonly retain = vi.fn<ISessions['retain']>((target) => {
const release = vi.fn<() => void>()
const sessionId = typeof target === 'string' ? target : target.childSessionId
const binding = { sessionId } as SessionReference['binding']
const reference: SessionReference = {
sessionId,
binding,
ready: Promise.resolve(binding),
release,
[Symbol.dispose]: release,
}
this.retained.push({ reference, release })
return reference
})
readonly subagentAddress = vi.fn<ISessions['subagentAddress']>()
declare readonly using: ISessions['using']
declare readonly retainInfo: ISessions['retainInfo']
declare readonly searchResultLimit: ISessions['searchResultLimit']
declare readonly setSubagentCatalogOpen: ISessions['setSubagentCatalogOpen']
declare readonly refresh: ISessions['refresh']
declare readonly search: ISessions['search']
declare readonly scope: ISessions['scope']
declare readonly scopeOf: ISessions['scopeOf']
declare readonly sessionOf: ISessions['sessionOf']
declare readonly binding: ISessions['binding']
constructor(initial: SessionListState) {
this.list = new MutableSource(initial)
this.create = vi.fn<ISessions['create']>(async options =>
options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`))
this.open = vi.fn((id: SessionId) => {
this.list.update(state => ({ ...state, current: id }))
})
this.clear = vi.fn(() => {
this.list.update(state => ({ ...state, current: undefined }))
})
}
}
@@ -201,10 +241,12 @@ class FakeDirectoryPicker {
interface BenchOptions {
readonly workspaces?: WorkspaceSnapshot
readonly sessions?: SessionListState
readonly configureSessions?: (sessions: FakeSessions) => void
}
function bench(options: BenchOptions = {}) {
const ctx = new Context()
contexts.push(ctx)
const layout = new LayoutController({
selectPanel: vi.fn(), retainMainPanels: vi.fn(),
setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(),
@@ -215,343 +257,328 @@ function bench(options: BenchOptions = {}) {
ctx.effect(() => () => { layout.dispose() })
const directoryPicker = new FakeDirectoryPicker()
const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
const sessions = new FakeSessions(options.sessions ?? sessionState([], 'pending'))
options.configureSessions?.(sessions)
const uiWorkspace = new UiWorkspaceService(
ctx,
directoryPicker.remote,
workspaces,
sessions as unknown as ISessions,
sessions,
)
return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel }
}
async function flush(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('UiWorkspaceService', () => {
it('selects a Session before revealing its Conversation, including the current Session', () => {
const current = sid('current')
const b = bench({ sessions: sessionState([summary('current')], current) })
b.selectPanel.mockImplementation(() => {
expect(b.sessions.list.getSnapshot().current).toBe(current)
})
b.uiWorkspace.openSession(current)
expect(b.sessions.open).toHaveBeenCalledWith(current)
it('retains an explicit main target before revealing its Conversation', () => {
const b = bench()
b.uiWorkspace.openSession(sid('target'))
expect(b.selectPanel).toHaveBeenCalledWith(null)
expect(b.sessions.open.mock.invocationCallOrder[0]).toBeLessThan(b.selectPanel.mock.invocationCallOrder[0]!)
expect(b.sessions.retain).toHaveBeenCalledWith(sid('target'), { source: 'mainView' })
expect(b.sessions.refreshSubagents).toHaveBeenCalledWith(sid('target'))
})
it('keeps the current panel when selecting a Session throws', () => {
it('keeps the current panel when retaining the target fails', () => {
const b = bench()
b.sessions.open.mockImplementationOnce(() => { throw new Error('selection failed') })
expect(() => { b.uiWorkspace.openSession(sid('target')) }).toThrow('selection failed')
b.sessions.retain.mockImplementationOnce(() => { throw new Error('open failed') })
expect(() => { b.uiWorkspace.openSession(sid('target')) }).toThrow('open failed')
expect(b.selectPanel).not.toHaveBeenCalled()
})
it('leaves a later panel selection in place when New Session finishes', async () => {
it('releases a newly retained target when Workspace preparation throws', async () => {
const b = bench({
sessions: sessionState([summary('current')], sid('current')),
workspaces: workspaceState([workspace('alpha')]),
workspaces: workspaceState([workspace('a')]),
sessions: sessionState([], 'pending'),
})
const created = Promise.withResolvers<SessionId>()
b.sessions.create.mockReturnValue(created.promise)
const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
b.uiWorkspace.startSession(wid('alpha'))
b.layout.selectPanel('panel-a' as MainPanelId)
created.resolve(sid('late'))
await opening.mock.results[0]!.value
expect(b.sessions.open).not.toHaveBeenCalled()
expect(b.selectPanel).toHaveBeenCalledExactlyOnceWith('panel-a')
expect(b.sessions.list.getSnapshot().current).toBe(sid('current'))
})
it('opens only the latest Workspace request when creation completes out of order', async () => {
const b = bench({
sessions: sessionState([summary('current')], sid('current')),
workspaces: workspaceState([workspace('alpha'), workspace('beta')]),
})
const older = Promise.withResolvers<SessionId>()
const newer = Promise.withResolvers<SessionId>()
b.sessions.create.mockImplementation(options => options?.workspaceId === wid('alpha') ? older.promise : newer.promise)
const oldDraft = vi.fn()
const newDraft = vi.fn()
const first = b.uiWorkspace.openWorkspace(wid('alpha'), oldDraft)
const second = b.uiWorkspace.openWorkspace(wid('beta'), newDraft)
newer.resolve(sid('newer'))
await second
older.resolve(sid('older'))
await first
expect(oldDraft).not.toHaveBeenCalled()
expect(newDraft).toHaveBeenCalledExactlyOnceWith(sid('newer'))
expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('newer'))
})
it('does not move drafts or reopen a Workspace after reselecting the current Session', async () => {
const b = bench({
sessions: sessionState([summary('current')], sid('current')),
workspaces: workspaceState([workspace('alpha')]),
})
const created = Promise.withResolvers<SessionId>()
b.sessions.create.mockReturnValue(created.promise)
const moveDraft = vi.fn()
const pending = b.uiWorkspace.openWorkspace(wid('alpha'), moveDraft)
b.uiWorkspace.openSession(sid('current'))
created.resolve(sid('late'))
await pending
expect(moveDraft).not.toHaveBeenCalled()
expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('current'))
const failure = new Error('preparation failed')
await expect(b.uiWorkspace.openWorkspace(wid('a'), () => { throw failure })).rejects.toBe(failure)
expect(b.sessions.retained.map(item => item.reference.sessionId)).toEqual([sid('current'), sid('created-a')])
expect(b.sessions.retained[0]!.release).not.toHaveBeenCalled()
expect(b.sessions.retained[1]!.release).toHaveBeenCalledOnce()
})
it('keeps navigation performed by preparation and propagates preparation failures', async () => {
const b = bench({
sessions: sessionState([summary('current')], sid('current')),
workspaces: workspaceState([workspace('alpha')]),
})
await b.uiWorkspace.openWorkspace(wid('alpha'), () => { b.layout.selectPanel('panel-a' as MainPanelId) })
expect(b.sessions.open).not.toHaveBeenCalled()
await expect(b.uiWorkspace.openWorkspace(wid('alpha'), () => { throw new Error('draft failed') })).rejects.toThrow('draft failed')
expect(b.sessions.open).not.toHaveBeenCalled()
it('opens only the latest Workspace when creation finishes out of order', async () => {
const b = bench({ workspaces: workspaceState([workspace('a'), workspace('b')]) })
const first = Promise.withResolvers<SessionId>()
const second = Promise.withResolvers<SessionId>()
b.sessions.create.mockImplementation(options => options?.workspaceId === wid('a') ? first.promise : second.promise)
const prepareA = vi.fn()
const prepareB = vi.fn()
const openingA = b.uiWorkspace.openWorkspace(wid('a'), prepareA)
const openingB = b.uiWorkspace.openWorkspace(wid('b'), prepareB)
second.resolve(sid('newer'))
await openingB
first.resolve(sid('older'))
await openingA
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('newer'), { source: 'mainView' })
expect(prepareB).toHaveBeenCalledExactlyOnceWith(sid('newer'))
expect(prepareA).not.toHaveBeenCalled()
})
it('opens a fork only while its navigation is current and preserves fork failures', async () => {
const b = bench()
await b.uiWorkspace.forkSession(sid('source'))
expect(b.sessions.fork).toHaveBeenCalledWith({ sessionId: sid('source'), increaseTitle: true })
expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('forked'))
const forked = Promise.withResolvers<SessionId>()
b.sessions.fork.mockReturnValueOnce(forked.promise)
const pending = b.uiWorkspace.forkSession(sid('source'))
b.layout.selectPanel('panel-a' as MainPanelId)
forked.resolve(sid('late-fork'))
await pending
expect(b.sessions.open).toHaveBeenCalledOnce()
b.sessions.fork.mockRejectedValueOnce(new Error('fork failed'))
await expect(b.uiWorkspace.forkSession(sid('source'))).rejects.toThrow('fork failed')
expect(b.sessions.open).toHaveBeenCalledOnce()
})
it('does not open a pending Workspace or fork after the navigation owner is disposed', async () => {
for (const kind of ['workspace', 'fork'] as const) {
const b = bench({
sessions: sessionState([summary('current')], sid('current')),
workspaces: workspaceState([workspace('alpha')]),
})
const resolved = Promise.withResolvers<SessionId>()
b.sessions.create.mockReturnValue(resolved.promise)
b.sessions.fork.mockReturnValue(resolved.promise)
const pending = kind === 'workspace'
? b.uiWorkspace.openWorkspace(wid('alpha'))
: b.uiWorkspace.forkSession(sid('current'))
await b.ctx.fiber.dispose()
resolved.resolve(sid('late'))
await pending
expect(b.sessions.open).not.toHaveBeenCalled()
it('does not reopen a Workspace after a later panel or Session navigation', async () => {
for (const panel of [true, false]) {
const b = bench({ workspaces: workspaceState([workspace('a')]) })
const created = Promise.withResolvers<SessionId>()
b.sessions.create.mockReturnValueOnce(created.promise)
const opening = b.uiWorkspace.openWorkspace(wid('a'))
if (panel) b.layout.selectPanel('other-panel' as MainPanelId)
else b.uiWorkspace.openSession(sid('chosen'))
created.resolve(sid('late'))
await opening
expect(b.sessions.retain.mock.calls.map(args => args[0])).toEqual(panel ? [] : [sid('chosen')])
}
})
it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
const b = bench()
const memberBlank = sid('member-blank')
const archivedBlank = sid('archived-blank')
const summaries: readonly SessionSummary[] = [
summary('stray', { blank: true, cwd: '/w/alpha' }),
summary('member-blank', { blank: true, cwd: '/w/alpha' }),
summary('active', { cwd: '/w/beta' }),
summary('archived-blank', { blank: true, cwd: '/w/gamma' }),
]
b.workspaces.list.set(workspaceState([
workspace('alpha', [memberBlank]),
workspace('beta', [sid('active')]),
workspace('gamma', [archivedBlank]),
], [archivedBlank]))
b.sessions.list.set({
...sessionState(summaries, memberBlank),
ids: [sid('missing'), ...summaries.map(item => item.id)],
})
await expect(Promise.all([
b.uiWorkspace.connectWorkspace(wid('alpha')),
b.uiWorkspace.connectWorkspace(wid('alpha')),
])).resolves.toEqual([memberBlank, memberBlank])
expect(b.sessions.create).not.toHaveBeenCalled()
const creation = Promise.withResolvers<SessionId>()
b.sessions.create.mockImplementation(() => creation.promise)
const first = b.uiWorkspace.connectWorkspace(wid('beta'))
const second = b.uiWorkspace.connectWorkspace(wid('beta'))
expect(b.sessions.create).toHaveBeenCalledTimes(1)
creation.resolve(sid('fresh-beta'))
await expect(Promise.all([first, second])).resolves.toEqual([sid('fresh-beta'), sid('fresh-beta')])
b.sessions.create.mockImplementation(async options => sid(`fresh-${String(options?.workspaceId)}`))
await expect(b.uiWorkspace.connectWorkspace(wid('gamma'))).resolves.toBe(sid('fresh-gamma'))
expect(b.sessions.create).toHaveBeenLastCalledWith({ workspaceId: wid('gamma') })
await expect(b.uiWorkspace.connectWorkspace(wid('ghost')))
.rejects.toThrow('uiWorkspace.connectWorkspace: unknown workspace ghost')
it('does not deliver pending Workspace and fork targets after disposal', async () => {
for (const kind of ['workspace', 'fork'] as const) {
const b = bench({ workspaces: workspaceState([workspace('a')]) })
const created = Promise.withResolvers<SessionId>()
b.sessions.create.mockReturnValueOnce(created.promise)
b.sessions.fork.mockReturnValueOnce(created.promise)
const pending = kind === 'workspace' ? b.uiWorkspace.openWorkspace(wid('a')) : b.uiWorkspace.forkSession(sid('source'))
await b.ctx.fiber.dispose()
created.resolve(sid('late'))
await pending
expect(b.sessions.retain).not.toHaveBeenCalled()
}
})
it('targets an explicit, current-session, then recent Workspace and reports failed starts', async () => {
it('ignores a rejected startup selection and stale catalog callbacks after disposal', async () => {
const created = Promise.withResolvers<SessionId>()
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const b = bench({
workspaces: workspaceState([workspace('a')], [], 'ready'),
sessions: sessionState([], 'pending'),
configureSessions: (sessions) => { sessions.create.mockReturnValue(created.promise) },
})
const staleReconcile = b.sessions.list.listenersSnapshot()[0]!
b.sessions.list.set(sessionState())
await b.ctx.fiber.dispose()
staleReconcile()
created.reject(new Error('late failure'))
await Promise.resolve()
expect(warning).not.toHaveBeenCalled()
})
it('does not run startup selection after a main Session was chosen while catalogs loaded', () => {
const b = bench()
b.uiWorkspace.openSession(sid('chosen'))
b.workspaces.list.set(workspaceState([workspace('a')]))
b.sessions.list.set(sessionState())
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('chosen'), { source: 'mainView' })
expect(b.sessions.create).not.toHaveBeenCalled()
})
it('forwards fork policy and rejects a failed fork', async () => {
const b = bench()
await b.uiWorkspace.forkSession(sid('source'))
expect(b.sessions.fork).toHaveBeenCalledWith({ sessionId: sid('source'), increaseTitle: true })
expect(b.sessions.retain).toHaveBeenCalledWith(sid('forked'), { source: 'mainView' })
b.sessions.fork.mockRejectedValueOnce(new Error('fork failed'))
await expect(b.uiWorkspace.forkSession(sid('source'))).rejects.toThrow('fork failed')
})
it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
const b = bench({
sessions: sessionState([
summary('stray', { blank: true, cwd: '/w/a' }),
summary('blank', { blank: true, cwd: '/w/a' }),
summary('archived', { blank: true, cwd: '/w/b' }),
]),
workspaces: workspaceState([workspace('a', [sid('blank')]), workspace('b', [sid('archived')])], [sid('archived')]),
})
await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('blank'))
expect(b.sessions.create).not.toHaveBeenCalled()
b.sessions.retain.mockClear()
const created = Promise.withResolvers<SessionId>()
b.sessions.create.mockReturnValue(created.promise)
const first = b.uiWorkspace.connectWorkspace(wid('b'))
const second = b.uiWorkspace.connectWorkspace(wid('b'))
expect(b.sessions.create).toHaveBeenCalledOnce()
created.resolve(sid('new'))
await expect(Promise.all([first, second])).resolves.toEqual([sid('new'), sid('new')])
await expect(b.uiWorkspace.connectWorkspace(wid('missing'))).rejects.toThrow('unknown workspace')
expect(b.sessions.retain).not.toHaveBeenCalled()
})
it('uses only an explicit Workspace or the recent-Workspace policy for new Sessions', async () => {
const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 })
const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 })
const b = bench({
sessions: sessionState([current, recent], current.id),
sessions: sessionState([current, recent]),
workspaces: workspaceState([
workspace('old'),
workspace('current-home', [current.id]),
workspace('recent-home', [recent.id]),
]),
})
b.uiWorkspace.startSession(wid('old'))
await vi.waitFor(() => {
expect(b.sessions.retain).toHaveBeenLastCalledWith(sid('created-old'), { source: 'mainView' })
})
b.uiWorkspace.openSession(current.id)
b.uiWorkspace.startSession()
await vi.waitFor(() => {
expect(b.sessions.retain).toHaveBeenLastCalledWith(sid('created-current-home'), { source: 'mainView' })
})
const recentOnly = bench({
sessions: sessionState([current, recent]),
workspaces: workspaceState([
workspace('current-home', [current.id]),
workspace('recent-home', [recent.id]),
]),
})
b.sessions.create.mockImplementation(async options => sid(`opened-${String(options?.workspaceId)}`))
recentOnly.uiWorkspace.startSession()
await vi.waitFor(() => {
expect(recentOnly.sessions.retain).toHaveBeenLastCalledWith(sid('created-recent-home'), { source: 'mainView' })
})
b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
b.uiWorkspace.startSession(wid('recent-home'))
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
})
b.sessions.open(current.id)
b.uiWorkspace.startSession()
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-current-home'))
})
b.sessions.clear()
b.uiWorkspace.startSession()
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
})
await vi.waitFor(() => { expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error)) })
const empty = bench()
empty.uiWorkspace.startSession()
expect(empty.sessions.clear).toHaveBeenCalledOnce()
expect(empty.selectPanel).toHaveBeenCalledWith(null)
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
b.uiWorkspace.startSession(wid('recent-home'))
const missingMember = bench({
sessions: sessionState(),
workspaces: workspaceState([
workspace('older', [sid('missing')], '2026-01-01T00:00:00.000Z'),
workspace('newer', [], '2026-02-01T00:00:00.000Z'),
]),
})
missingMember.uiWorkspace.startSession()
await vi.waitFor(() => {
expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error))
expect(missingMember.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newer') })
})
})
it('opens the recent Workspace after both baselines arrive', async () => {
const b = bench()
b.sessions.create.mockResolvedValue(sid('initial'))
it('releases a prepared Workspace target when synchronous preparation supersedes it', async () => {
const b = bench({ workspaces: workspaceState([workspace('a')]) })
const stableFirst = workspace('stable-first', [], '2026-01-01T00:00:00.000Z')
const recent = workspace('recent', [], '2026-01-02T00:00:00.000Z')
b.workspaces.list.set(workspaceState([stableFirst, recent]))
expect(b.sessions.create).not.toHaveBeenCalled()
b.sessions.list.set(sessionState())
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
await b.uiWorkspace.openWorkspace(wid('a'), () => {
b.uiWorkspace.openSession(sid('override'))
})
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('recent') })
expect(b.workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual([
wid('stable-first'), wid('recent'),
expect(b.sessions.retained.map(item => item.reference.sessionId)).toEqual([
sid('created-a'), sid('override'),
])
expect(b.sessions.retained[0]!.release).toHaveBeenCalledOnce()
expect(b.sessions.retained[1]!.release).not.toHaveBeenCalled()
})
it('uses Workspace creation time when members are absent and preserves Host tie order', async () => {
it('opens the most recent Workspace after both startup catalogs become ready', async () => {
const b = bench()
b.sessions.create.mockResolvedValue(sid('initial'))
b.workspaces.list.set(workspaceState([
workspace('newest', [sid('missing')], '2026-03-01T00:00:00.000Z'),
workspace('newest', [], '2026-03-01T00:00:00.000Z'),
workspace('same-time', [], '2026-03-01T00:00:00.000Z'),
workspace('older', [], '2026-01-01T00:00:00.000Z'),
]))
b.sessions.list.set(sessionState())
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
expect(b.sessions.retain).toHaveBeenCalledWith(sid('created-newest'), { source: 'mainView' })
})
expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newest') })
})
it('retries failed initial selection and never overwrites a later selection', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const b = bench()
let attempts = 0
b.sessions.create.mockImplementation(() => ++attempts === 1
? Promise.reject(new Error('attach exploded'))
: Promise.resolve(sid('retry')))
b.workspaces.list.set(workspaceState([workspace('recent')]))
b.sessions.list.set(sessionState())
await vi.waitFor(() => {
expect(warning).toHaveBeenCalledWith('initial workspace selection failed:', expect.any(Error))
})
b.workspaces.list.update(state => ({ ...state, items: [...state.items] }))
await vi.waitFor(() => {
expect(b.sessions.open).toHaveBeenCalledWith(sid('retry'))
})
expect(attempts).toBe(2)
const changed = bench()
const pending = Promise.withResolvers<SessionId>()
changed.sessions.create.mockImplementation(() => pending.promise)
changed.workspaces.list.set(workspaceState([workspace('recent')]))
changed.sessions.list.set(sessionState())
await vi.waitFor(() => { expect(changed.sessions.create).toHaveBeenCalledOnce() })
changed.sessions.open(sid('manual'))
pending.resolve(sid('automatic'))
await flush()
expect(changed.sessions.open).toHaveBeenCalledTimes(1)
expect(changed.sessions.open).toHaveBeenCalledWith(sid('manual'))
})
it('stops initial navigation when its Cordis lifetime is disposed', async () => {
const success = bench()
const resolved = Promise.withResolvers<SessionId>()
success.sessions.create.mockImplementation(() => resolved.promise)
success.workspaces.list.set(workspaceState([workspace('recent')]))
success.sessions.list.set(sessionState())
await vi.waitFor(() => { expect(success.sessions.create).toHaveBeenCalledOnce() })
await success.ctx.fiber.dispose()
resolved.resolve(sid('late'))
await flush()
expect(success.sessions.open).not.toHaveBeenCalled()
success.workspaces.list.set(workspaceState([workspace('ignored')]))
expect(success.sessions.create).toHaveBeenCalledOnce()
const failure = bench()
const rejected = Promise.withResolvers<SessionId>()
failure.sessions.create.mockImplementation(() => rejected.promise)
failure.workspaces.list.set(workspaceState([workspace('recent')]))
failure.sessions.list.set(sessionState())
await vi.waitFor(() => { expect(failure.sessions.create).toHaveBeenCalledOnce() })
const staleReconciles = failure.workspaces.list.listenersSnapshot()
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
await failure.ctx.fiber.dispose()
rejected.reject(new Error('late failure'))
await flush()
for (const reconcile of staleReconciles) reconcile()
expect(warning).not.toHaveBeenCalled()
expect(failure.sessions.create).toHaveBeenCalledOnce()
})
it('clears a current Session only after it enters the archive baseline', () => {
const current = summary('current')
const idle = summary('idle')
it('does not let a pending startup Workspace replace a manual Session selection', async () => {
const created = Promise.withResolvers<SessionId>()
const b = bench({
sessions: sessionState([current, idle], current.id),
workspaces: workspaceState([workspace('one', [current.id, idle.id])]),
workspaces: workspaceState([workspace('a')]),
sessions: sessionState(),
configureSessions: (sessions) => { sessions.create.mockReturnValue(created.promise) },
})
b.uiWorkspace.openSession(sid('chosen'))
created.resolve(sid('automatic'))
await b.uiWorkspace.connectWorkspace(wid('a'))
expect(b.sessions.retain.mock.calls.map(([target]) => target)).toEqual([sid('chosen')])
})
it('restores a persisted subagent address without a parent catalog', () => {
const address: SubagentAddress = {
parentSessionId: sid('parent'),
childSessionId: sid('child'),
mode: 'continuable',
}
persistSelection({ sessionId: address.childSessionId, subagentAddress: address })
const b = bench({
workspaces: workspaceState(),
sessions: sessionState(),
})
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
expect(b.sessions.clear).not.toHaveBeenCalled()
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [current.id] }))
expect(b.sessions.clear).toHaveBeenCalledOnce()
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(address, { source: 'mainView' })
expect(b.sessions.refreshSubagents.mock.calls).toEqual([
[address.parentSessionId],
[address.childSessionId],
])
})
b.sessions.open(idle.id)
b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
expect(b.sessions.clear).toHaveBeenCalledTimes(2)
const archived = bench({
sessions: sessionState([current], current.id),
workspaces: workspaceState([workspace('one', [current.id])], [current.id]),
it('persists a catalog-resolved address after string subagent navigation', () => {
const address: SubagentAddress = {
parentSessionId: sid('parent'),
childSessionId: sid('child'),
mode: 'continuable',
}
const backing = persistSelection({})
const b = bench({
configureSessions: (sessions) => { sessions.subagentAddress.mockReturnValue(address) },
})
expect(archived.sessions.clear).toHaveBeenCalledOnce()
b.uiWorkspace.openSession(address.childSessionId)
expect(JSON.parse(backing.get('dsh.sessions.current')!)).toEqual({
sessionId: address.childSessionId,
subagentAddress: address,
})
})
it('reports and retries a failed persisted Session restoration', () => {
const failure = new Error('restore failed')
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const sessions = sessionState([summary('saved')])
persistSelection({ sessionId: sid('saved') })
const b = bench({
workspaces: workspaceState(),
sessions,
configureSessions: (face) => {
face.retain.mockImplementationOnce(() => { throw failure })
},
})
expect(warning).toHaveBeenCalledWith('initial Session restoration failed:', failure)
b.sessions.list.set(sessions)
expect(b.sessions.retain).toHaveBeenCalledTimes(2)
expect(b.sessions.retained).toHaveLength(1)
expect(b.sessions.retained[0]!.reference.sessionId).toBe(sid('saved'))
})
it('clears a selected Session when an external archive snapshot arrives', () => {
const b = bench()
b.uiWorkspace.openSession(sid('current'))
b.workspaces.list.set(workspaceState([], [sid('current')]))
expect(b.sessions.retained[0]!.release).toHaveBeenCalledOnce()
expect(b.selectPanel).toHaveBeenCalledTimes(2)
})
it('clears a selected Session after archiving it without an intervening snapshot', async () => {
const b = bench()
b.workspaces.onArchive = async () => {}
b.uiWorkspace.openSession(sid('current'))
await b.uiWorkspace.archiveSession(sid('current'))
expect(b.sessions.retained[0]!.release).toHaveBeenCalledOnce()
expect(b.selectPanel).toHaveBeenCalledTimes(2)
})
it('forwards archive commands and preserves failures', async () => {
@@ -85,9 +85,8 @@ async function bench(options: {
},
})
const navigation: unknown[] = []
let current = options.addressed === true ? CHILD : SESSION
let mainSessionId = options.addressed === true ? CHILD : SESSION
ctx.provide('sessions', {
list: { getSnapshot: () => ({ current }) },
binding: (id: SessionId) => options.addressed === true && id === CHILD
? { session: { getSnapshot: () => ({
subagent: {
@@ -103,8 +102,17 @@ async function bench(options: {
navigation.push(['refresh', id])
return options.refreshGate ?? Promise.resolve()
},
openSubagent: (address: unknown) => { navigation.push(['open', address]) },
retainInfo: (id: SessionId) => ({
getSnapshot: () => ({
referenceCount: id === mainSessionId ? 1 : 0,
retainedBy: id === mainSessionId ? { mainView: 1 } : {},
}),
subscribe: () => () => {},
}),
})
ctx.provide('uiWorkspace', {
openSession: (target: unknown) => { navigation.push(['open', target]) },
} as never)
ctx.provide('conversation', {})
ctx.provide('locale', new LocaleRuntime(ctx))
await ctx.plugin(SlotRegistry).await()
@@ -137,14 +145,14 @@ async function bench(options: {
remote,
entry,
collapseHeader,
select: (sessionId: SessionId) => { current = sessionId },
select: (sessionId: SessionId) => { mainSessionId = sessionId },
}
}
describe('ui-team browser plugin', () => {
it('registers one disposable header action with RPC-backed task operations', async () => {
const b = await bench()
expect(inject).toEqual(['sessions', 'remote', 'slots', 'locale'])
expect(inject).toEqual(['sessions', 'uiWorkspace', 'remote', 'slots', 'locale'])
expect(b.entry()).toMatchObject({
options: { id: 'agent-team', order: 20 },
locale: 'agent-team',
@@ -22,7 +22,7 @@ describe('Client Cordis inspect catalog', () => {
])
expect(SERVICE_API.find(service => service.key === 'uiWorkspace')?.methods.map(method => method.signature))
.toEqual([
'openSession(sessionId: SessionId): void',
'openSession(target: SessionTarget): void',
'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise<void>',
'forkSession(sessionId: SessionId): Promise<void>',
'connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>',
@@ -55,6 +55,8 @@ export interface SessionFixture {
events?: readonly SessionEventLikeEntry[]
/** Whether the initial event window has an older page. */
hasMore?: boolean
/** Optional shared initial-opening barrier for the fixture's Client generation. */
initialOpen?: (signal: AbortSignal) => void | Promise<void>
}
/**
@@ -28,14 +28,17 @@ import { createSlotRenderer as createRenderer } from '@deepseek-ai/dsh-client-ui
import {
apply as applyUiSession, inject as uiSessionInject,
} from '@deepseek-ai/dsh-client-ui-session/client'
import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client'
import type {
ChildrenDecl, ComposedProps, HostObservable, OwnerOf, RenderOpts, SlotComponent, SlotMap, SlotRenderer,
SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike,
ScopedStandardSourceBinding, SessionProviderComponent, SlotEntryDef, SlotRendererHost, SlotSpec,
SnapshotSelectorHook, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
import { registerDomSnapshotSerializer } from './snapshot.ts'
import { TestRemote } from './remote.ts'
import { TestSessions } from './sessions.ts'
import { TestWorkspaces } from './workspaces.ts'
import type { Stabilizer } from './fixtures.ts'
@@ -76,6 +79,11 @@ export function createSlotRenderer(): SlotRenderer {
/** Erased register face for the internal root call (the public declaration contract holds the typing). */
type ErasedRegister = (options: object, component: unknown) => () => void
/** Per-view render options; the caller owns the explicitly supplied Session reference. */
export interface SlotTestRenderOptions extends RenderOpts {
readonly session?: SessionReference | undefined
}
/**
* One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}:
* the renderer's own `[data-slot]` outlet anchor is the snapshot root
@@ -92,8 +100,9 @@ export interface SlotView<K extends keyof SlotMap & string> {
* Replace the owner props and flush the re-render (the render-site update:
* in production the owner recomputes the share and React re-renders).
* @param owner - the next owner props share.
* @param opts - replacement render options and borrowed reference; omission keeps this view's options.
*/
update(owner: OwnerOf<K>): void
update(owner: OwnerOf<K>, opts?: SlotTestRenderOptions): void
}
/**
@@ -123,7 +132,7 @@ export interface TestFileUpload {
* {@link SlotView.update} drive React through the standard uSES boundary.
*/
class OwnerPropsCell {
private readonly owners = new Map<string, { owner: object; opts: RenderOpts | undefined }>()
private readonly owners = new Map<string, { owner: object; opts: SlotTestRenderOptions | undefined }>()
private readonly listeners = new Set<() => void>()
private version = 0
@@ -147,14 +156,14 @@ class OwnerPropsCell {
* @param owner - owner props share.
* @param opts - explicit keyed or list selection for the render site.
*/
set(key: string, owner: object, opts?: RenderOpts): void {
set(key: string, owner: object, opts?: SlotTestRenderOptions): void {
this.owners.set(key, { owner, opts })
this.version += 1
for (const fn of [...this.listeners]) fn()
}
/** Keys with supplied owner props, in first-supply order. */
entries(): readonly (readonly [string, { owner: object; opts: RenderOpts | undefined }])[] {
entries(): readonly (readonly [string, { owner: object; opts: SlotTestRenderOptions | undefined }])[] {
return [...this.owners.entries()]
}
}
@@ -212,8 +221,10 @@ export class SlotTestRuntime {
readonly slots: SlotRegistry
/** The test-owned 'root' occupant. */
readonly root: TestRoot
/** Sessions double (list/current observable, cells, scopes, behavior faces). */
/** Fixture catalog, explicit references, scoped contexts, and behavior faces. */
readonly sessions: TestSessions
/** One Remote double shared by every feature mounted in this runtime. */
readonly remote: TestRemote
/** Workspaces double (list observable, recorded intent actions). */
readonly workspaces: TestWorkspaces
/** Test-owned panel selection used by the framework's usePanelInfo hook. */
@@ -241,6 +252,7 @@ export class SlotTestRuntime {
this.slots = slots
this.root = new TestRoot(slots, this.stabilizer)
this.sessions = new TestSessions(this.stabilizer, ctx)
this.remote = new TestRemote(ctx)
this.workspaces = new TestWorkspaces(this.stabilizer)
this.fileUpload = {
upload: () => Promise.reject(new Error('client test runtime: file upload is not stubbed')),
@@ -340,13 +352,25 @@ export class SlotTestRuntime {
async declare(children: ChildrenDecl): Promise<void> {
for (const key of Object.keys(children)) this.autoDeclared.add(key)
const cell = this.ownerCell
const AutoFrame = (props: { renderSlot: (key: string, owner: object, opts?: RenderOpts) => ReactNode }) => {
const AutoFrame = (props: {
renderSlot: (key: string, owner: object, opts?: RenderOpts) => ReactNode
SessionProvider: SessionProviderComponent
}) => {
useSyncExternalStore(cell.subscribe, cell.getVersion)
// Keyed Fragments only: the renderer's outlet anchor is the one
// `[data-slot]` element — the frame adding its own would nest
// duplicate anchors under the same key.
return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) =>
createElement(Fragment, { key }, props.renderSlot(key, owner, opts))))
return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) => {
const body = props.renderSlot(key, owner, opts)
const spec = children[key as keyof typeof children] as SlotSpec<SlotEntryDef>
if (spec.scope === 'root') return createElement(Fragment, { key }, body)
return createElement(props.SessionProvider, {
key,
session: opts?.session,
children: body,
...spec.scope === 'session-maybe' ? { empty: () => body } : {},
})
}))
}
await this.root.declare(children as never, AutoFrame as never)
}
@@ -359,17 +383,23 @@ export class SlotTestRuntime {
* slot of the same tree.
* @param key - a key declared through {@link SlotTestRuntime.declare}.
* @param owner - owner props share for the render site.
* @param opts - explicit keyed or list selection; retained by view updates.
* @param opts - explicit entry selection and borrowed Session reference; retained by view updates.
* @returns the slot-local view (snapshot container, scoped queries, owner updates).
*/
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>, opts?: RenderOpts): SlotView<K> {
renderSlot<K extends keyof SlotMap & string>(
key: K,
owner: OwnerOf<K>,
opts?: SlotTestRenderOptions,
): SlotView<K> {
if (!this.autoDeclared.has(key)) {
throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`)
}
const install = (next: object): void => {
let options = opts
const install = (next: object, nextOptions = options): void => {
options = nextOptions
// Synchronous cell write inside act: the frame re-renders through uSES.
act(() => {
this.ownerCell.set(key, next, opts)
this.ownerCell.set(key, next, options)
})
}
install(owner)
@@ -387,20 +417,22 @@ export class SlotTestRuntime {
* {@link SlotTestRuntime.renderRoot} — the host face exists only inside the
* installed renderer, exactly as in production.
* @param key - slot key whose first entry declares the store.
* @param scopeKey - session id for session-scope slots; omit for root scope.
* @param session - retained Session reference for session-scope slots; omit for root scope.
* @returns the live store instance.
*/
storeOf(key: keyof SlotMap & string, scopeKey?: string): StoreInstanceLike {
storeOf(key: keyof SlotMap & string, session?: SessionReference): StoreInstanceLike {
if (this.host === undefined) {
throw new Error('storeOf before renderRoot() — the host face exists only inside the installed renderer')
}
const entry = this.host.entriesOf(key)[0]
if (entry === undefined) throw new Error(`storeOf('${key}'): no registration on the ledger`)
const scopeBinding = scopeKey === undefined
const adapter = session === undefined ? undefined : this.host.scope('session')
const resolved = session === undefined ? undefined : adapter?.bindingSource(session).getSnapshot()
const scopeBinding = resolved?.key === undefined
? undefined
: this.host.scope('session')?.resolve(scopeKey)
if (scopeKey !== undefined && scopeBinding === undefined) {
throw new Error(`storeOf('${key}'): no live Session binding for '${scopeKey}'`)
: resolved as ScopedStandardSourceBinding
if (session !== undefined && scopeBinding === undefined) {
throw new Error(`storeOf('${key}'): no live Session binding for '${session.sessionId}'`)
}
const instance = this.host.storeOf(entry, scopeBinding)
if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`)
@@ -432,6 +464,7 @@ export class SlotTestRuntime {
this.disposeWorkspaceSource()
this.disposePanelInfoSource()
await this.sessions.disposeScopes()
await this.stabilizer(() => this.ctx.fiber.dispose())
localStorage.clear()
}
}
@@ -38,17 +38,34 @@ export class TestRemote {
* @param ctx - the spec's root Context.
* @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
*/
constructor(ctx: Context, namespaces: Readonly<Record<string, object>> = {}) {
constructor(private readonly ctx: Context, namespaces: Readonly<Record<string, object>> = {}) {
this.validateNamespaces(namespaces)
ctx.provide('remote', this)
this.installNamespaces(namespaces)
}
/**
* Add scripted namespace faces to this Remote service.
* @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
*/
provideNamespaces(namespaces: Readonly<Record<string, object>>): void {
this.validateNamespaces(namespaces)
this.installNamespaces(namespaces)
}
private validateNamespaces(namespaces: Readonly<Record<string, object>>): void {
for (const name of Object.keys(namespaces)) {
// A namespace named after one of the double's own members would replace
// it, and `$mount`'s rejection is the contract a spec relies on.
if (name in TestRemote.prototype || name === 'subscriptions' || name === '$host') {
if (name in this) {
throw new TypeError(`TestRemote: scripted namespace "${name}" would shadow the double's own member`)
}
}
}
private installNamespaces(namespaces: Readonly<Record<string, object>>): void {
Object.assign(this, namespaces)
ctx.provide('remote', this)
for (const [name, face] of Object.entries(namespaces)) ctx.provide(`remote.${name}`, face)
for (const [name, face] of Object.entries(namespaces)) this.ctx.provide(`remote.${name}`, face)
}
/**
@@ -7,8 +7,10 @@ import {
import type {
AgentContext, ISessions, ProjectionsFace, SessionBinding, SessionFace, SessionListState,
SessionEventLikeEntry, SessionLiveEventEntry, SessionSearchResultItem,
SessionSnapshot, SessionSummary, SubmissionHandle,
SessionReference, SessionReferenceSource, SessionRetainInfo, SessionRetainOptions,
SessionSnapshot, SessionSummary, SessionTarget, SubmissionHandle,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { scopeIdentityOf } from '@deepseek-ai/dsh-api-session-controller/src/client/scope.ts'
import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -19,6 +21,14 @@ import type {
SessionFixture, SessionFixtureSnapshot, Stabilizer,
} from './fixtures.ts'
declare module '@deepseek-ai/dsh-api-session-controller/client' {
interface SessionReferenceSourceMap {
testFixture: unknown
testView: unknown
testOperation: unknown
}
}
/**
* The fixture-backed session face: lifecycle reads delegate to the fixture's
* snapshot store; Session verbs are fail-loud stubs unless the
@@ -169,36 +179,139 @@ export class FixtureSession implements SessionFace {
}
}
/** One live test session: fixture-derived stores plus its minted scope state. */
/** Catalog fixture data remains available across live Client generations. */
interface SessionRecord {
summary: SessionSummary
snapshot: SnapshotStore<SessionFixtureSnapshot>
session: FixtureSession
scope: AgentContext | undefined
scopeFiber: { dispose(): Promise<void> } | undefined
binding: SessionBinding | undefined
overrides: Record<string, unknown>
projections: Map<string, unknown>
initialOpen: SessionFixture['initialOpen']
}
interface SessionGeneration {
readonly binding: SessionBinding
readonly snapshot: SnapshotStore<SessionFixtureSnapshot>
readonly session: FixtureSession
readonly fiber: { dispose(): Promise<void> }
readonly lifetime: AbortController
readonly opening: Promise<void>
retention: SessionRetainInfo
live: boolean
}
interface OpeningControl {
readonly promise: Promise<void>
readonly resolve: () => void
readonly reject: (error: unknown) => void
}
function freezeRetainedBy(
counts: Partial<Record<SessionReferenceSource, number>>,
): SessionRetainInfo['retainedBy'] {
Object.setPrototypeOf(counts, null)
return Object.freeze(counts)
}
const EMPTY_RETAIN_INFO: SessionRetainInfo = Object.freeze({
referenceCount: 0,
retainedBy: freezeRetainedBy({}),
})
// TestSessions implements SessionReference against its fixture-owned
// SessionGeneration without importing the production service's private record.
/* jscpd:ignore-start -- The fixture intentionally mirrors production SessionReference settlement and release semantics. */
async function waitForOpen(opening: Promise<void>, signal?: AbortSignal): Promise<void> {
/* v8 ignore next -- TestSessionReference always supplies its release-composed signal. */
if (signal === undefined) return opening
const aborted = Promise.withResolvers<never>()
const onAbort = (): void => { aborted.reject(signal.reason) }
signal.addEventListener('abort', onAbort, { once: true })
try {
if (signal.aborted) onAbort()
await Promise.race([opening, aborted.promise])
} finally {
signal.removeEventListener('abort', onAbort)
}
}
class TestSessionReference implements SessionReference {
private readonly released = new AbortController()
private readonly readiness = Promise.withResolvers<SessionBinding>()
readonly ready = this.readiness.promise
constructor(
readonly sessionId: SessionId,
private generation: SessionGeneration | undefined,
private releaseReference: (() => void) | undefined,
) {
void this.ready.catch(() => {})
}
get binding(): SessionBinding {
if (this.generation === undefined || !this.generation.live) {
throw new Error(`Session reference "${this.sessionId}" is released`)
}
return this.generation.binding
}
attachOpening(opening: Promise<void>, signal?: AbortSignal): void {
const waitSignal = signal === undefined
? this.released.signal
: AbortSignal.any([this.released.signal, signal])
void waitForOpen(opening, waitSignal).then(
() => {
try {
waitSignal.throwIfAborted()
this.readiness.resolve(this.binding)
} catch (error: unknown) {
this.readiness.reject(error)
}
},
(error: unknown) => { this.readiness.reject(error) },
)
}
release(): void {
const reason = new Error(`Session reference "${this.sessionId}" is released`)
const release = this.releaseReference
this.released.abort(reason)
this.readiness.reject(reason)
this.generation = undefined
this.releaseReference = undefined
release?.()
}
[Symbol.dispose](): void {
this.release()
}
}
/* jscpd:ignore-end */
/**
* Sessions test double behind the renderer host and feature injects: owns the
* list/current observable, scope minting through the production `createScope`,
* catalog observable, scope minting through the production `createScope`,
* stable Controller bindings, and the session behavior face supplied per
* fixture. `ui-session` owns standard-source materialization.
*
* Implements the same ISessions face features receive as `ctx.sessions`, so
* a production face change breaks this double at compile time; the extra
* members (add/updateSessionSnapshot/event-window drivers/setCurrent/remove/
* members (add/updateSessionSnapshot/event-window drivers/remove/
* behavior/calls/stubs) are bench-only surface.
*/
export class TestSessions implements ISessions {
/** The useSessions standard feed (list rows + current selection). */
/** The useSessions catalog feed, independent of view ownership. */
readonly list: SnapshotStore<SessionListState>
private readonly records = new Map<SessionId, SessionRecord>()
private readonly generations = new Map<SessionId, SessionGeneration>()
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly retentionStores = new Map<SessionId, SnapshotStore<SessionRetainInfo>>()
private readonly pendingDrops = new Set<Promise<void>>()
private closed = false
/** Calls observed on the service-level face, newest last. */
readonly calls: {
method: 'create' | 'open' | 'openSubagent' | 'setSubagentCatalogOpen' | 'refreshSubagents'
| 'clear' | 'refresh' | 'search' | 'fork'
method: 'create' | 'setSubagentCatalogOpen' | 'refreshSubagents' | 'refresh' | 'search' | 'fork'
args: unknown[]
}[] = []
@@ -215,18 +328,27 @@ export class TestSessions implements ISessions {
*/
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {},
})
rootCtx.effect(() => async () => {
this.closed = true
for (const [id, generation] of this.generations) {
generation.live = false
generation.retention = EMPTY_RETAIN_INFO
generation.lifetime.abort(new Error('test Session Controller is disposed'))
this.publishRetention(id)
}
this.generations.clear()
await this.drainDrops()
}, 'test sessions: Client generations')
}
/**
* Add a session from a fixture and (by default) make it current.
* Add a Session fixture to the catalog without retaining a generation.
* @param fixture - identity + snapshot/summary overrides + behavior face.
* @param opts - pass `current: false` to add without selecting.
* @returns the stable session id (branded view of `fixture.id`).
*/
async add(fixture: SessionFixture, opts?: { current?: boolean }): Promise<SessionId> {
async add(fixture: SessionFixture): Promise<SessionId> {
const id = fixture.id as SessionId
if (this.records.has(id)) throw new Error(`test session "${id}" already added`)
const summary: SessionSummary = {
@@ -236,6 +358,7 @@ export class TestSessions implements ISessions {
blank: false,
updatedAt: this.records.size + 1,
...fixture.summary,
retainedBy: this.retentionSnapshot(id).retainedBy,
}
const snapshot = createSnapshotStore<SessionFixtureSnapshot>({
...sessionSnapshot(id),
@@ -249,15 +372,14 @@ export class TestSessions implements ISessions {
summary,
snapshot,
session,
scope: undefined,
scopeFiber: undefined,
binding: undefined,
overrides: fixture.session ?? {},
projections: new Map(),
initialOpen: fixture.initialOpen,
})
await this.stabilize(() => {
this.list.update((draft) => {
draft.ids.push(id)
draft.byId[id] = summary
if (opts?.current !== false) draft.current = id
})
})
return id
@@ -273,7 +395,10 @@ export class TestSessions implements ISessions {
mutate: (draft: SessionFixtureSnapshot) => void,
): Promise<void> {
const record = this.require(id)
await this.stabilize(() => { record.snapshot.update(mutate) })
await this.stabilize(() => {
record.snapshot.update(mutate)
this.generations.get(id as SessionId)?.snapshot.set(record.snapshot.getSnapshot())
})
}
/**
@@ -284,7 +409,11 @@ export class TestSessions implements ISessions {
*/
async setProjection(id: string, key: string, value: unknown): Promise<void> {
const record = this.require(id)
await this.stabilize(() => { record.session.projections.set(key, value) })
record.projections.set(key, value)
await this.stabilize(() => {
record.session.projections.set(key, value)
this.generations.get(id as SessionId)?.session.projections.set(key, value)
})
}
/**
@@ -298,7 +427,10 @@ export class TestSessions implements ISessions {
entries: readonly SessionEventLikeEntry[],
hasMore = false,
): Promise<void> {
await this.stabilize(() => { this.require(id).session.eventSource.replace(entries, hasMore) })
await this.stabilize(() => {
this.require(id).session.eventSource.replace(entries, hasMore)
this.generations.get(id as SessionId)?.session.eventSource.replace(entries, hasMore)
})
}
/**
@@ -312,7 +444,10 @@ export class TestSessions implements ISessions {
entries: readonly SessionEventLikeEntry[],
hasMore = false,
): Promise<void> {
await this.stabilize(() => { this.require(id).session.eventSource.prepend(entries, hasMore) })
await this.stabilize(() => {
this.require(id).session.eventSource.prepend(entries, hasMore)
this.generations.get(id as SessionId)?.session.eventSource.prepend(entries, hasMore)
})
}
/**
@@ -321,7 +456,10 @@ export class TestSessions implements ISessions {
* @param entry - live event entry.
*/
async appendEvent(id: string, entry: SessionLiveEventEntry): Promise<void> {
await this.stabilize(() => { this.require(id).session.eventSource.append(entry) })
await this.stabilize(() => {
this.require(id).session.eventSource.append(entry)
this.generations.get(id as SessionId)?.session.eventSource.append(entry)
})
}
/**
@@ -332,71 +470,115 @@ export class TestSessions implements ISessions {
*/
async updateSummary(id: string, patch: Partial<Omit<SessionSummary, 'id'>>): Promise<void> {
const record = this.require(id)
record.summary = { ...record.summary, ...patch }
record.summary = {
...record.summary,
...patch,
retainedBy: this.retentionSnapshot(id as SessionId).retainedBy,
}
await this.stabilize(() => {
this.list.update((draft) => { draft.byId[id as SessionId] = record.summary })
})
}
/**
* Switch the current selection (undefined = the no-session empty state).
* @param id - session id to select, or undefined to clear.
*/
async setCurrent(id: string | undefined): Promise<void> {
if (id !== undefined) this.require(id)
await this.stabilize(() => {
this.list.update((draft) => { draft.current = id as SessionId | undefined })
})
}
/**
* Remove a session: list row, scope fiber, and per-session store instances
* (with persisted state) die together — the same single lifecycle axis the
* production Client Sessions service drives on session death, minus staging.
* Remove a catalog row and mark its retained Session removed without releasing owners.
* @param id - session id.
*/
async remove(id: string): Promise<void> {
const record = this.require(id)
this.require(id)
this.records.delete(id as SessionId)
await this.stabilize(async () => {
await this.stabilize(() => {
this.list.update((draft) => {
draft.ids = draft.ids.filter(existing => existing !== id)
const { [id as SessionId]: _dead, ...rest } = draft.byId
draft.byId = rest
if (draft.current === id) draft.current = undefined
})
if (record.scopeFiber !== undefined) await record.scopeFiber.dispose()
this.generations.get(id as SessionId)?.snapshot.update((draft) => { draft.removed = true })
})
}
/**
* Resolve (mint on first touch) the session-scoped Cordis context through
* the production `createScope`, so real `scopeOf`/scope-addressed services
* resolve it.
* Borrow the already-retained session-scoped Cordis context.
* @param id - session id.
* @returns the scoped context, or undefined for unknown sessions.
* @returns the scoped context, or undefined without a live reference.
*/
scope(id: string): AgentContext | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
if (record.scope === undefined) {
const handle = createScope(this.rootCtx, id as SessionId)
record.scope = handle.ctx
record.scopeFiber = handle.fiber
}
return record.scope
return this.generations.get(id as SessionId)?.binding.ctx
}
/**
* Session assembly binding (inject factories and provide resolvers receive it).
* @param id - session id.
* @returns sessionId + behavior face + scoped ctx, or undefined when unknown.
* @returns the live generation's binding, or undefined without a reference.
*/
binding(id: string): SessionBinding | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
record.binding ??= this.bindingOf(id as SessionId, record)
return record.binding
return this.generations.get(id as SessionId)?.binding
}
/* jscpd:ignore-start -- The fixture intentionally mirrors production SessionReference acquisition and release semantics. */
retain(
target: SessionTarget,
options: SessionRetainOptions = { source: 'testFixture' },
): SessionReference {
const { source, signal } = options
signal?.throwIfAborted()
if (this.closed) throw new Error('test Session Controller is disposed')
const id = this.resolveTarget(target)
const generation = this.generations.get(id) ?? this.materialize(id, this.require(id))
const reference = this.retainGeneration(id, generation, source)
try {
reference.attachOpening(generation.opening, signal)
return reference
} catch (error: unknown) {
reference.release()
throw error
}
}
async using<T>(
target: SessionTarget,
options: SessionRetainOptions,
operation: (reference: SessionReference) => T | Promise<T>,
): Promise<T> {
const reference = this.retain(target, options)
try {
await reference.ready
return await operation(reference)
} finally {
reference.release()
}
}
/* jscpd:ignore-end */
retainInfo(id: SessionId): ObservableSnapshot<SessionRetainInfo> {
let store = this.retentionStores.get(id)
if (store === undefined) {
store = createSnapshotStore(this.retentionSnapshot(id))
this.retentionStores.set(id, store)
}
return store
}
/**
* Retain one fixture Session until the supplied Cordis owner stops.
* @param ownerCtx - context whose disposal releases the reference.
* @param target - fixture Session identity or subagent address.
* @param options - reference source and optional readiness cancellation.
* @returns the owned reference immediately.
*/
retainFor(
ownerCtx: Context,
target: SessionTarget,
options: SessionRetainOptions = { source: 'testFixture' },
): SessionReference {
const reference = this.retain(target, options)
try {
ownerCtx.effect(() => () => { reference.release() }, 'test sessions: owned reference')
return reference
} catch (error: unknown) {
reference.release()
throw error
}
}
/**
@@ -417,7 +599,11 @@ export class TestSessions implements ISessions {
sessionOf(ctx: Context): SessionFace | undefined {
const id = scopeOf(ctx)
if (id === undefined) return undefined
return this.records.get(id)?.session
const generation = this.generations.get(id)
return generation !== undefined
&& scopeIdentityOf(generation.binding.ctx) === scopeIdentityOf(ctx)
? generation.session
: undefined
}
/**
@@ -428,7 +614,7 @@ export class TestSessions implements ISessions {
this.createStub = impl
}
/** Create through the installed test behavior and require an addressable binding. */
/** Create through the installed test behavior and require a catalogued fixture. */
async create(opts?: Parameters<ISessions['create']>[0]): Promise<SessionId> {
this.calls.push({ method: 'create', args: [opts] })
if (this.createStub === undefined) {
@@ -439,35 +625,17 @@ export class TestSessions implements ISessions {
return id
}
/**
* Service-level selection call (recorded, then applied to the list store
* synchronously — inject callbacks call this outside any act window; the
* store notify is microtask-batched so the next stabilized step observes it).
* @param id - session id.
*/
open(id: SessionId): void {
this.calls.push({ method: 'open', args: [id] })
this.require(id)
this.list.update((draft) => {
draft.current = id
draft.currentAddress = undefined
})
}
/** Open an existing fixture through its catalog address. */
openSubagent(address: SubagentAddress): void {
this.calls.push({ method: 'openSubagent', args: [address] })
this.require(address.childSessionId)
this.list.update((draft) => {
draft.current = address.childSessionId
draft.currentAddress = address
})
}
/** Resolve the current fixture's retained catalog address. */
/** Resolve a retained or catalog-derived address independently of a view. */
subagentAddress(id: SessionId): SubagentAddress | undefined {
const address = this.list.getSnapshot().currentAddress
return address?.childSessionId === id ? address : undefined
const retained = this.addresses.get(id)
if (retained !== undefined) return retained
for (const [parentSessionId, catalog] of Object.entries(this.list.getSnapshot().subagentsByParent)) {
const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === id)
if (child?.kind === 'child') {
return { parentSessionId: parentSessionId as SessionId, childSessionId: id, mode: child.mode }
}
}
return undefined
}
/** Record catalog consumption; fixture callers drive snapshots explicitly. */
@@ -481,15 +649,6 @@ export class TestSessions implements ISessions {
return Promise.resolve()
}
/** Clear the current selection (recorded; the production no-session flow). */
clear(): void {
this.calls.push({ method: 'clear', args: [] })
this.list.update((draft) => {
draft.current = undefined
draft.currentAddress = undefined
})
}
/** Record a list refresh; fixture callers publish list state explicitly. */
refresh(): Promise<void> {
this.calls.push({ method: 'refresh', args: [] })
@@ -535,32 +694,142 @@ export class TestSessions implements ISessions {
* @returns the FixtureSession carried by the Controller binding.
*/
behavior(id: string): FixtureSession {
return this.require(id).session
return this.generations.get(id as SessionId)?.session ?? this.require(id).session
}
/** Dispose minted scope fibers (runtime dispose path). */
async disposeScopes(): Promise<void> {
for (const record of this.records.values()) {
if (record.scopeFiber !== undefined) {
await record.scopeFiber.dispose()
record.scope = undefined
record.scopeFiber = undefined
record.binding = undefined
this.closed = true
for (const [id, generation] of this.generations) this.drop(id, generation)
await this.drainDrops()
}
private resolveTarget(target: SessionTarget): SessionId {
const id = typeof target === 'string' ? target : target.childSessionId
if (typeof target !== 'string') {
this.addresses.set(id, target)
}
this.require(id)
return id
}
private retainGeneration(
id: SessionId,
generation: SessionGeneration,
source: SessionReferenceSource,
): TestSessionReference {
const previous = generation.retention
generation.retention = Object.freeze({
referenceCount: previous.referenceCount + 1,
retainedBy: freezeRetainedBy({
...previous.retainedBy,
[source]: (previous.retainedBy[source] ?? 0) + 1,
}),
})
const reference = new TestSessionReference(id, generation, () => {
if (!generation.live) return
const count = generation.retention.referenceCount - 1
const { [source]: sourceCount = 0, ...otherSources } = generation.retention.retainedBy
const retainedBy = sourceCount > 1
? { ...otherSources, [source]: sourceCount - 1 }
: otherSources
generation.retention = count === 0
? EMPTY_RETAIN_INFO
: Object.freeze({ referenceCount: count, retainedBy: freezeRetainedBy(retainedBy) })
if (count === 0) this.drop(id, generation)
else this.publishRetention(id)
})
this.publishRetention(id)
return reference
}
private retentionSnapshot(id: SessionId): SessionRetainInfo {
return this.generations.get(id)?.retention ?? EMPTY_RETAIN_INFO
}
private publishRetention(id: SessionId): void {
const retention = this.retentionSnapshot(id)
const store = this.retentionStores.get(id)
if (store !== undefined && store.getSnapshot() !== retention) store.set(retention)
const state = this.list.getSnapshot()
const row = state.byId[id]
if (row === undefined || row.retainedBy === retention.retainedBy) return
const summary = { ...row, retainedBy: retention.retainedBy }
const record = this.records.get(id)
/* v8 ignore next -- a catalog row and its fixture record are inserted and removed together. */
if (record !== undefined) record.summary = summary
this.list.set({ ...state, byId: { ...state.byId, [id]: summary } })
}
private materialize(id: SessionId, fixture: SessionRecord): SessionGeneration {
const { ctx, fiber } = createScope(this.rootCtx, id)
const snapshot = createSnapshotStore(fixture.snapshot.getSnapshot())
const session = new FixtureSession(id, snapshot, fixture.overrides)
const window = fixture.session.eventSource.getSnapshot()
session.eventSource.replace(window.entries, window.hasMore)
for (const [key, value] of fixture.projections) session.projections.set(key, value)
const opening = Promise.withResolvers<void>()
void opening.promise.catch(() => {})
const lifetime = new AbortController()
const generation: SessionGeneration = {
binding: { sessionId: id, session, eventSource: session.eventSource, ctx },
snapshot,
session,
fiber,
lifetime,
opening: opening.promise,
retention: EMPTY_RETAIN_INFO,
live: true,
}
this.generations.set(id, generation)
ctx.effect(() => async () => {
if (generation.live) {
generation.live = false
generation.retention = EMPTY_RETAIN_INFO
if (this.generations.get(id) === generation) this.generations.delete(id)
this.publishRetention(id)
}
lifetime.abort(new Error(`test Session generation "${id}" is disposed`))
await Promise.allSettled([generation.opening])
}, 'test sessions: exact generation')
this.startOpening(fixture.initialOpen, lifetime.signal, opening)
return generation
}
private startOpening(
initialOpen: SessionFixture['initialOpen'],
signal: AbortSignal,
opening: OpeningControl,
): void {
try {
Promise.resolve(initialOpen?.(signal)).then(opening.resolve, opening.reject)
} catch (error: unknown) {
opening.reject(error)
}
}
private bindingOf(id: SessionId, record: SessionRecord): SessionBinding {
const ctx = this.scope(id)
/* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope
* always resolves; kept so a future caller cannot mint a ctx-less binding. */
if (ctx === undefined) throw new Error(`test session "${id}" resolved no scope`)
return {
sessionId: id,
session: record.session,
eventSource: record.session.eventSource,
ctx,
}
private drop(id: SessionId, generation: SessionGeneration): void {
/* v8 ignore next -- only the live generation's retained callback can enter drop. */
if (!generation.live) return
generation.live = false
generation.retention = EMPTY_RETAIN_INFO
/* v8 ignore next -- this synchronous path drops only the generation currently stored for id. */
if (this.generations.get(id) === generation) this.generations.delete(id)
generation.lifetime.abort(new Error(`test Session generation "${id}" is released`))
this.publishRetention(id)
const disposal = generation.fiber.dispose()
this.pendingDrops.add(disposal)
void disposal.then(
() => { this.pendingDrops.delete(disposal) },
(error: unknown) => {
this.pendingDrops.delete(disposal)
this.rootCtx.logger.warn('test Session scope disposal failed:', error)
},
)
}
private async drainDrops(): Promise<void> {
while (this.pendingDrops.size !== 0) await Promise.all([...this.pendingDrops])
}
private require(id: string): SessionRecord {
@@ -46,20 +46,16 @@ function entry(seq: number): SessionLiveEventEntry {
}
describe('fixture helpers', () => {
it.each([false, true])('retracts default root sources without removing replacements (release first: %s)', async (releaseFirst) => {
it('retracts default root sources without removing their live replacements', async () => {
const runtime = await SlotTestRuntime.create()
const hooks = { workspaces: runtime.workspaces.list, panelInfo: runtime.panelInfo }
let releaseReplacement: (() => void) | undefined
try {
if (releaseFirst) {
runtime.releaseWorkspaceSource()
runtime.releasePanelInfoSource()
} else {
await runtime.dispose()
}
runtime.releaseWorkspaceSource()
runtime.releasePanelInfoSource()
releaseReplacement = runtime.slots.provideRoot({ hooks })
await runtime.dispose()
await runtime.dispose()
runtime.releaseWorkspaceSource()
runtime.releasePanelInfoSource()
for (const key of ['workspaces', 'panelInfo'] as const) {
expect(() => runtime.slots.provideRoot({ hooks: { [key]: hooks[key] } }))
.toThrow(`duplicate root standard hook '${key}'`)
@@ -146,14 +142,14 @@ describe('Session fixture lifecycle', () => {
const older = entry(0)
const live = entry(2)
await runtime.sessions.add({ id: 'events', events: [first] }, { current: false })
await runtime.sessions.add({ id: 'events', events: [first] })
expect(runtime.sessions.behavior('events').eventSource.getSnapshot()).toMatchObject({
entries: [first],
hasMore: false,
change: { kind: 'replace', entries: [first] },
})
await runtime.sessions.add({ id: 'has-more', hasMore: true }, { current: false })
await runtime.sessions.add({ id: 'has-more', hasMore: true })
expect(runtime.sessions.behavior('has-more').eventSource.getSnapshot()).toMatchObject({
entries: [],
hasMore: true,
@@ -173,7 +169,7 @@ describe('Session fixture lifecycle', () => {
it('requires an explicit create stub and records successful create and refresh calls', async () => {
const runtime = await SlotTestRuntime.create()
await expect(runtime.sessions.create()).rejects.toThrow(/create is not stubbed/)
await runtime.sessions.add({ id: 'created' }, { current: false })
await runtime.sessions.add({ id: 'created' })
const create = vi.fn(() => Promise.resolve('created' as SessionId))
runtime.sessions.stubCreate(create)
@@ -187,9 +183,12 @@ describe('Session fixture lifecycle', () => {
await runtime.dispose()
})
it('disposes a scope without materializing a binding', async () => {
it('borrows only retained scopes and disposes their generation with the runtime', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.sessions.add({ id: 'scope-only' }, { current: false })
await runtime.sessions.add({ id: 'scope-only' })
expect(runtime.sessions.scope('scope-only')).toBeUndefined()
const reference = runtime.sessions.retain('scope-only' as SessionId)
await reference.ready
const scope = runtime.sessions.scope('scope-only')
expect(scope).toBeDefined()
const release = vi.fn()
@@ -198,5 +197,6 @@ describe('Session fixture lifecycle', () => {
runtime.releaseWorkspaceSource()
await runtime.dispose()
expect(release).toHaveBeenCalledOnce()
expect(() => reference.binding).toThrow('released')
})
})
@@ -7,18 +7,25 @@
* stack — this suite is the fixture the migrated feature specs rely on.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope } from '../src/settings-scope.ts'
import { cleanup } from '@testing-library/react'
import { defineStore } from '@deepseek-ai/dsh-client-store'
import { act, cleanup } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import { createSnapshotStore, defineStore } from '@deepseek-ai/dsh-client-store'
import { createScope, type SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PropsRenderSlots, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type {
PropsRenderSlots, SessionStandardProps, SlotRendererHost,
} from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
'trt.panel': { kind: 'single'; scope: 'root'; owner: { label?: string } }
'trt.chat': { kind: 'single'; scope: 'session' }
'trt.other-chat': { kind: 'single'; scope: 'session' }
'trt.maybe': { kind: 'single'; scope: 'session-maybe' }
'trt.rows': { kind: 'list'; scope: 'root' }
'trt.rows.hole': { kind: 'single'; scope: 'root' }
}
@@ -26,14 +33,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
afterEach(cleanup)
type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'>
type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'> & { reference: SessionReference | undefined }
/** Root frame declaring all three suite slots (render sites for each kind). */
function Frame({ renderSlot, SessionProvider }: FrameProps) {
function Frame({ renderSlot, SessionProvider, reference }: FrameProps) {
return (
<>
{renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
<SessionProvider empty={() => <i>no session</i>}>
<SessionProvider session={reference} empty={() => <i>no session</i>}>
{renderSlot('trt.chat', {})}
</SessionProvider>
{renderSlot('trt.rows', {})}
@@ -47,9 +54,14 @@ const CHILDREN = {
'trt.rows': { kind: 'list', scope: 'root' },
} as const
async function runtimeWithFrame() {
async function runtimeWithFrame(reference = createSnapshotStore<SessionReference | undefined>(undefined)) {
const runtime = await SlotTestRuntime.create()
await runtime.root.declare(CHILDREN, Frame)
const subscribe = reference.subscribe.bind(reference)
const getSnapshot = reference.getSnapshot.bind(reference)
await runtime.root.declare(CHILDREN, (props) => {
const bound = useSyncExternalStore(subscribe, getSnapshot)
return <Frame {...props} reference={bound} />
})
return runtime
}
@@ -82,8 +94,9 @@ describe('root declaration and rendering', () => {
})
describe('sessions', () => {
it('drives SessionProvider: empty state, current session, switch, live snapshot updates', async () => {
const runtime = await runtimeWithFrame()
it('drives SessionProvider with explicit references and live snapshot updates', async () => {
const reference = createSnapshotStore<SessionReference | undefined>(undefined)
const runtime = await runtimeWithFrame(reference)
runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => {
const running = props.useSession(s => s.running)
return <span>chat:{props.sessionId}:{String(running)}</span>
@@ -92,29 +105,36 @@ describe('sessions', () => {
expect(view.container.textContent).toContain('no session')
await runtime.sessions.add({ id: 's1' })
using first = runtime.sessions.retain('s1' as SessionId)
await first.ready
act(() => { reference.set(first) })
expect(view.container.textContent).toContain('chat:s1:false')
await runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.running = true })
expect(view.container.textContent).toContain('chat:s1:true')
await runtime.sessions.add({ id: 's2' }) // becomes current by default
await runtime.sessions.add({ id: 's2' })
using second = runtime.sessions.retain('s2' as SessionId)
await second.ready
act(() => { reference.set(second) })
expect(view.container.textContent).toContain('chat:s2:false')
await runtime.sessions.setCurrent(undefined)
act(() => { reference.set(undefined) })
expect(view.container.textContent).toContain('no session')
await runtime.sessions.setCurrent('s1')
act(() => { reference.set(first) })
expect(view.container.textContent).toContain('chat:s1:true')
await runtime.dispose()
})
it('add with current:false keeps the selection; unknown ids fail loud on the mutators', async () => {
it('adds catalog entries without retaining them and rejects unknown mutation targets', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' }, { current: false })
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
await runtime.sessions.add({ id: 's2' })
expect(runtime.sessions.binding('s1')).toBeUndefined()
expect(runtime.sessions.binding('s2')).toBeUndefined()
expect(runtime.sessions.list.getSnapshot().ids).toEqual(['s1', 's2'])
await expect(runtime.sessions.add({ id: 's1' })).rejects.toThrow(/already added/)
await expect(runtime.sessions.setCurrent('ghost')).rejects.toThrow(/not added/)
expect(() => runtime.sessions.retain('ghost' as SessionId)).toThrow(/not added/)
await expect(runtime.sessions.updateSessionSnapshot('ghost', () => {})).rejects.toThrow(/not added/)
await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/)
expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/)
@@ -125,6 +145,9 @@ describe('sessions', () => {
const runtime = await runtimeWithFrame()
const prompt = vi.fn()
await runtime.sessions.add({ id: 's1', session: { prompt } })
using reference = runtime.sessions.retain('s1' as SessionId)
await reference.ready
expect(reference.binding.sessionId).toBe('s1')
expect(runtime.sessions.scope('ghost')).toBeUndefined()
expect(runtime.sessions.binding('ghost')).toBeUndefined()
@@ -136,6 +159,9 @@ describe('sessions', () => {
// sessionOf resolves the behavior face off the scope tag.
expect(runtime.sessions.sessionOf(scope)).toBe(runtime.sessions.behavior('s1'))
expect(runtime.sessions.sessionOf(runtime.ctx)).toBeUndefined()
const foreign = createScope(runtime.ctx, 's1' as SessionId)
expect(runtime.sessions.sessionOf(foreign.ctx)).toBeUndefined()
await foreign.fiber.dispose()
const binding = runtime.sessions.binding('s1')!
expect(binding.sessionId).toBe('s1')
@@ -151,18 +177,16 @@ describe('sessions', () => {
await runtime.dispose()
})
it('records service-face calls and retains catalog addresses only for addressed selection', async () => {
it('accepts explicit addresses without a catalog and keeps them independent of references', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' })
const address = {
parentSessionId: 's2' as SessionId,
childSessionId: 's1' as SessionId,
mode: 'continuable' as const,
parentSessionId: 's2' as SessionId, childSessionId: 's1' as SessionId, mode: 'continuable' as const,
}
runtime.sessions.openSubagent(address)
await runtime.flush()
expect(runtime.sessions.list.getSnapshot()).toMatchObject({ current: 's1', currentAddress: address })
using reference = runtime.sessions.retain(address)
await reference.ready
expect(reference.sessionId).toBe('s1')
expect(runtime.sessions.subagentAddress('s1' as SessionId)).toEqual(address)
expect(runtime.sessions.subagentAddress('s2' as SessionId)).toBeUndefined()
await runtime.sessions.updateSummary('s1', { displayTitle: 'renamed', running: true })
@@ -170,27 +194,197 @@ describe('sessions', () => {
.toMatchObject({ displayTitle: 'renamed', running: true })
runtime.sessions.setSubagentCatalogOpen('s2' as SessionId, true)
await runtime.sessions.refreshSubagents('s2' as SessionId)
runtime.sessions.open('s1' as SessionId)
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
expect(runtime.sessions.list.getSnapshot().currentAddress).toBeUndefined()
runtime.sessions.clear()
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
reference.release()
expect(runtime.sessions.binding('s1')).toBeUndefined()
expect(runtime.sessions.subagentAddress('s1' as SessionId)).toEqual(address)
await expect(runtime.sessions.fork({
sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true,
})).resolves.toBe('s1')
expect(runtime.sessions.calls).toEqual([
{ method: 'openSubagent', args: [address] },
{ method: 'setSubagentCatalogOpen', args: ['s2', true] },
{ method: 'refreshSubagents', args: ['s2'] },
{ method: 'open', args: ['s1'] },
{ method: 'clear', args: [] },
{ method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] },
])
await runtime.dispose()
})
it('resolves catalog addresses without retaining their parent', async () => {
const runtime = await runtimeWithFrame()
const parentId = 'parent' as SessionId
await runtime.sessions.add({ id: 'child' })
runtime.sessions.list.update((draft) => {
draft.subagentsByParent = {
[parentId]: {
state: 'ready', error: null, parentAvailable: true,
entries: [
{ kind: 'child', id: 'other' as SessionId, mode: 'one-shot', activity: 'inactive', hasChildren: false },
{ kind: 'child', id: 'child' as SessionId, mode: 'continuable', label: 'Child', activity: 'inactive', hasChildren: false },
],
},
}
})
expect(runtime.sessions.subagentAddress('child' as SessionId)).toEqual({
parentSessionId: parentId, childSessionId: 'child', mode: 'continuable',
})
expect(runtime.sessions.subagentAddress('missing' as SessionId)).toBeUndefined()
await runtime.dispose()
})
it('tracks repeated references and releases callback and Context-owned references', async () => {
const runtime = await runtimeWithFrame()
const id = await runtime.sessions.add({ id: 'owned' })
const info = runtime.sessions.retainInfo(id)
expect(runtime.sessions.retainInfo(id)).toBe(info)
const first = runtime.sessions.retain(id, { source: 'testOperation' })
const second = runtime.sessions.retain(id, { source: 'testOperation' })
const scope = first.binding.ctx
await Promise.all([first.ready, second.ready])
expect(info.getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { testOperation: 2 } })
expect(runtime.sessions.list.getSnapshot().byId[id]?.retainedBy).toEqual({ testOperation: 2 })
first.release()
expect(info.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { testOperation: 1 } })
second.release()
await scope.fiber.dispose()
expect(info.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
await expect(runtime.sessions.using(id, { source: 'testOperation' }, reference => reference.sessionId))
.resolves.toBe(id)
const failure = new Error('operation failed')
await expect(runtime.sessions.using(id, { source: 'testOperation' }, () => { throw failure }))
.rejects.toBe(failure)
const owner = new Context()
const owned = runtime.sessions.retainFor(owner, id)
await owned.ready
const ownedScope = owned.binding.ctx
await owner.fiber.dispose()
await ownedScope.fiber.dispose()
expect(info.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
await runtime.dispose()
})
it('releases a retained fixture when its Context owner rejects registration', async () => {
const runtime = await runtimeWithFrame()
const id = await runtime.sessions.add({ id: 'owner-failure' })
const owner = new Context()
const failure = new Error('owner rejected effect')
vi.spyOn(owner, 'effect').mockImplementation(() => { throw failure })
expect(() => runtime.sessions.retainFor(owner, id)).toThrow(failure)
expect(runtime.sessions.retainInfo(id).getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
await runtime.dispose()
})
it('releases a reference when readiness signal composition rejects it', async () => {
const runtime = await runtimeWithFrame()
const id = await runtime.sessions.add({ id: 'bad-signal' })
const malformed = { throwIfAborted: () => {} } as AbortSignal
expect(() => runtime.sessions.retain(id, {
source: 'testOperation', signal: malformed,
})).toThrow(TypeError)
expect(runtime.sessions.retainInfo(id).getSnapshot()).toEqual({
referenceCount: 0, retainedBy: {},
})
await runtime.dispose()
})
it('mirrors acquisition cancellation and generation disposal races', async () => {
const runtime = await runtimeWithFrame()
const blocked = Promise.withResolvers<undefined>()
const cancelledId = await runtime.sessions.add({
id: 'cancelled', initialOpen: () => blocked.promise,
})
const reason = new Error('cancelled while retaining')
const controller = new AbortController()
const info = runtime.sessions.retainInfo(cancelledId)
const off = info.subscribe(() => {
if (info.getSnapshot().referenceCount > 0) controller.abort(reason)
})
const cancelled = runtime.sessions.retain(cancelledId, {
source: 'testOperation', signal: controller.signal,
})
await expect(cancelled.ready).rejects.toBe(reason)
cancelled.release()
blocked.resolve(undefined)
off()
const opening = Promise.withResolvers<undefined>()
const disposedId = await runtime.sessions.add({ id: 'disposed', initialOpen: () => opening.promise })
const disposed = runtime.sessions.retain(disposedId)
const binding = disposed.binding
const scopeDisposal = binding.ctx.fiber.dispose()
opening.resolve(undefined)
await scopeDisposal
await expect(disposed.ready).rejects.toThrow('is released')
disposed.release()
await runtime.dispose()
})
it('propagates synchronous opening failures and reports scope-disposal failures', async () => {
const runtime = await runtimeWithFrame()
const openingFailure = new Error('opening failed')
const failedId = await runtime.sessions.add({
id: 'failed-open', initialOpen: () => { throw openingFailure },
})
const failed = runtime.sessions.retain(failedId)
const failedScope = failed.binding.ctx
await expect(failed.ready).rejects.toBe(openingFailure)
failed.release()
await failedScope.fiber.dispose()
const disposalId = await runtime.sessions.add({ id: 'failed-disposal' })
const reference = runtime.sessions.retain(disposalId)
await reference.ready
const scope = reference.binding.ctx
const dispose = scope.fiber.dispose.bind(scope.fiber)
const disposalFailure = new Error('scope disposal failed')
vi.spyOn(scope.fiber, 'dispose').mockRejectedValueOnce(disposalFailure)
const warning = vi.spyOn(runtime.ctx.logger, 'warn').mockImplementation(() => undefined)
reference.release()
await vi.waitFor(() => {
expect(warning).toHaveBeenCalledWith('test Session scope disposal failed:', disposalFailure)
})
await dispose()
await runtime.dispose()
})
it('invalidates live generations when the runtime Context ends', async () => {
const runtime = await runtimeWithFrame()
const id = await runtime.sessions.add({ id: 'live-at-root-disposal' })
const reference = runtime.sessions.retain(id)
await reference.ready
const info = runtime.sessions.retainInfo(id)
await runtime.ctx.fiber.dispose()
expect(info.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
expect(() => reference.binding).toThrow('is released')
expect(() => runtime.sessions.retain(id)).toThrow('disposed')
})
it('keeps a same-id replacement when stale generation cleanup arrives', async () => {
const runtime = await runtimeWithFrame()
const id = await runtime.sessions.add({ id: 'replacement' })
const old = runtime.sessions.retain(id)
await old.ready
const oldBinding = old.binding
const generations = (runtime.sessions as unknown as {
generations: Map<SessionId, unknown>
}).generations
generations.delete(id)
const replacement = runtime.sessions.retain(id)
await replacement.ready
await oldBinding.ctx.fiber.dispose()
expect(runtime.sessions.binding(id)).toBe(replacement.binding)
old.release()
replacement.release()
await runtime.dispose()
})
it('answers search with an empty page until a scenario declares hits, recording every call', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
@@ -222,7 +416,8 @@ describe('stores', () => {
})
it('resolves per-session instances via the host face: shared identity, isolation, action-driven re-render', async () => {
const runtime = await runtimeWithFrame()
const reference = createSnapshotStore<SessionReference | undefined>(undefined)
const runtime = await runtimeWithFrame(reference)
const handle = createSuiteStore()
runtime.slots.register(
{ name: 'trt.chat', store: handle },
@@ -230,17 +425,23 @@ describe('stores', () => {
<span>note:{props.useStore(s => s.note)}</span>)
const view = runtime.renderRoot()
await runtime.sessions.add({ id: 's1' })
using first = runtime.sessions.retain('s1' as SessionId)
await first.ready
act(() => { reference.set(first) })
expect(() => runtime.storeOf('trt.panel')).toThrow(/no registration/)
const store = runtime.storeOf('trt.chat', 's1')
const store = runtime.storeOf('trt.chat', first)
await runtime.flush()
;(store.actions['setNote'] as (note: string) => void)('hello')
await runtime.flush()
expect(view.container.textContent).toContain('note:hello')
expect(runtime.storeOf('trt.chat', 's1')).toBe(store) // cached per scope key
expect(runtime.storeOf('trt.chat', first)).toBe(store) // cached per scope key
await runtime.sessions.add({ id: 's2' })
const other = runtime.storeOf('trt.chat', 's2')
using second = runtime.sessions.retain('s2' as SessionId)
await second.ready
act(() => { reference.set(second) })
const other = runtime.storeOf('trt.chat', second)
expect(other).not.toBe(store)
expect(other.getSnapshot()).toEqual({ note: '' })
await runtime.dispose()
@@ -253,43 +454,70 @@ describe('stores', () => {
expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/)
runtime.renderRoot()
expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/)
expect(() => runtime.storeOf('trt.chat', 'missing')).toThrow(/no live Session binding/)
const host = (runtime as unknown as { host: SlotRendererHost }).host
const adapter = host.scope('session')!
const bindingSource = vi.spyOn(adapter, 'bindingSource').mockReturnValue(createSnapshotStore({
key: undefined, hooks: {}, keyedHooks: {}, props: {},
}))
expect(() => runtime.storeOf('trt.chat', { sessionId: 'missing' as SessionId } as SessionReference))
.toThrow(/no live Session binding/)
bindingSource.mockRestore()
await runtime.sessions.add({ id: 'released' })
const released = runtime.sessions.retain('released' as SessionId)
released.release()
expect(() => runtime.storeOf('trt.chat', released)).toThrow(/is released/)
await runtime.dispose()
})
it('remove() prunes the session store scope: persisted state clears, a re-added session starts fresh', async () => {
const runtime = await runtimeWithFrame()
it('removal and reference release preserve the Session Store persistence', async () => {
const reference = createSnapshotStore<SessionReference | undefined>(undefined)
const runtime = await runtimeWithFrame(reference)
const handle = createSuiteStore()
runtime.slots.register({ name: 'trt.chat', store: handle }, () => null)
runtime.renderRoot()
await runtime.sessions.add({ id: 's1' })
using first = runtime.sessions.retain('s1' as SessionId)
await first.ready
act(() => { reference.set(first) })
const doomed = runtime.storeOf('trt.chat', 's1')
const doomed = runtime.storeOf('trt.chat', first)
;(doomed.actions['setNote'] as (note: string) => void)('buried')
expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
await runtime.sessions.remove('s1')
expect(localStorage.getItem('trt.store.s1')).toBeNull()
expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
expect(runtime.sessions.list.getSnapshot().ids).toEqual([])
expect(runtime.sessions.binding('s1')?.session.getSnapshot().removed).toBe(true)
expect(runtime.storeOf('trt.chat', first)).toBe(doomed)
await act(async () => { reference.set(undefined); first.release(); await runtime.flush() })
expect(localStorage.getItem('trt.store.s1')).not.toBeNull()
expect(runtime.sessions.binding('s1')).toBeUndefined()
await runtime.sessions.add({ id: 's1' })
const reborn = runtime.storeOf('trt.chat', 's1')
using replacement = runtime.sessions.retain('s1' as SessionId)
await replacement.ready
act(() => { reference.set(replacement) })
const reborn = runtime.storeOf('trt.chat', replacement)
expect(reborn).not.toBe(doomed)
expect(reborn.getSnapshot()).toEqual({ note: '' })
expect(reborn.getSnapshot()).toEqual({ note: 'buried' })
await runtime.dispose()
})
it('remove() also disposes a minted scope fiber; removing a non-current session keeps the selection', async () => {
it('removing catalog rows preserves an explicitly retained scope until release', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' }, { current: false })
await runtime.sessions.add({ id: 's2' })
using reference = runtime.sessions.retain('s1' as SessionId)
await reference.ready
const scope = runtime.sessions.scope('s1')!
await runtime.sessions.remove('s2')
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
expect(runtime.sessions.binding('s1')).toBe(reference.binding)
await runtime.sessions.remove('s1')
expect(scope.fiber.uid).toBeNull() // disposed fiber loses its uid
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
expect(scope.fiber.uid).not.toBeNull()
expect(runtime.sessions.binding('s1')).toBe(reference.binding)
await act(async () => { reference.release(); await runtime.flush() })
expect(scope.fiber.uid).toBeNull()
expect(runtime.sessions.binding('s1')).toBeUndefined()
await runtime.dispose()
})
})
@@ -367,6 +595,47 @@ describe('feature mount and disposal', () => {
})
describe('single-slot mounting (declare + renderSlot)', () => {
it('renders an absent session-maybe slot through its empty projection', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'trt.maybe': { kind: 'single', scope: 'session-maybe' } })
runtime.slots.register(
{ name: 'trt.maybe' },
({ sessionId }: { sessionId: SessionId | undefined }) => <b>{sessionId ?? 'no session'}</b>,
)
const slot = runtime.renderSlot('trt.maybe', {})
expect(slot.container.textContent).toBe('no session')
await runtime.dispose()
})
it('borrows separate explicit references for sibling views and updates only the supplied view', async () => {
const runtime = await SlotTestRuntime.create()
const firstId = await runtime.sessions.add({ id: 'view-first' })
const secondId = await runtime.sessions.add({ id: 'view-second' })
using first = runtime.sessions.retain(firstId)
await first.ready
using second = runtime.sessions.retain(secondId)
await second.ready
await runtime.declare({
'trt.chat': { kind: 'single', scope: 'session' },
'trt.other-chat': { kind: 'single', scope: 'session' },
})
runtime.slots.register({ name: 'trt.chat' }, ({ sessionId }: SessionStandardProps) => <b>{sessionId}</b>)
runtime.slots.register({ name: 'trt.other-chat' }, ({ sessionId }: SessionStandardProps) => <b>{sessionId}</b>)
const retain = vi.spyOn(runtime.sessions, 'retain')
const left = runtime.renderSlot('trt.chat', {}, { session: first })
const right = runtime.renderSlot('trt.other-chat', {}, { session: second })
expect(left.container.textContent).toBe('view-first')
expect(right.container.textContent).toBe('view-second')
left.update({}, { session: second })
expect(left.container.textContent).toBe('view-second')
expect(right.container.textContent).toBe('view-second')
expect(retain).not.toHaveBeenCalled()
expect(runtime.sessions.binding(firstId)).toBe(first.binding)
await runtime.dispose()
})
it('renders one slot inside its data-slot wrapper and updates owner props in place', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
@@ -441,12 +710,16 @@ describe('fixture session face', () => {
})
it('projects controller values through the real ui-session and renderer path', async () => {
const runtime = await runtimeWithFrame()
const reference = createSnapshotStore<SessionReference | undefined>(undefined)
const runtime = await runtimeWithFrame(reference)
runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => (
<span>todos:{props.useProjection('todos', value => value?.length ?? 0)}</span>
))
const view = runtime.renderRoot()
await runtime.sessions.add({ id: 's1' })
using bound = runtime.sessions.retain('s1' as SessionId)
await bound.ready
act(() => { reference.set(bound) })
expect(view.container.textContent).toContain('todos:0')
const session = runtime.sessions.behavior('s1')
@@ -468,6 +741,18 @@ describe('fixture session face', () => {
session.projections.set('untouched', 1)
await runtime.dispose()
})
it('copies projections into a later Session generation', async () => {
const runtime = await runtimeWithFrame()
const id = await runtime.sessions.add({ id: 'seeded-projection' })
await runtime.sessions.setProjection(id, 'todos', [1, 2])
using reference = runtime.sessions.retain(id)
await reference.ready
expect(reference.binding.session.projections.faceOf('todos').getSnapshot()).toEqual([1, 2])
await runtime.dispose()
})
})
describe('workspaces action face', () => {
@@ -530,9 +815,7 @@ describe('single-slot mounting edge arms', () => {
cleanup()
expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/rendered no wrapper/)
await runtime.dispose()
// After dispose the root registration is gone: the production boot-order
// check fires before any wrapper lookup.
expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/'root' has no registration/)
expect(() => runtime.renderSlot('trt.panel', {})).toThrow(/slot renderer not installed/)
})
it('serializes childless svg untouched next to scoped classes', async () => {
@@ -229,7 +229,7 @@
}
```
## B restored: normal mode and Files directory state
## B restored: normal mode and collapsed Files directory
```json
{
@@ -249,9 +249,7 @@
"panelOuterWidth": 757,
"coversViewport": false,
"resizeHandleWidth": 8,
"expandedDirectories": [
"workspace"
],
"expandedDirectories": [],
"panes": [
{
"active": true,