test(client): migrate Session reference coverage
This commit is contained in:
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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
+19
-7
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user