diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 7e069b2465..88ae171e85 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -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', diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 4299bf542c..5c4eb17d93 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -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() diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 6f6fb83f10..0f807e3b91 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -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) diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 44d4d02e82..68260d26bb 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -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' }) diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index d665afddb6..3f782c206f 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -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 () => { diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 5c5fe7e66f..55e0b1d137 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -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() + const reply = Promise.withResolvers>>() + const call = vi.fn(() => { + 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() + const handler = Promise.withResolvers() + 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() + const handler = Promise.withResolvers() + 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 | 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().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 () => { diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index ee973fc05f..bbb6e4836b 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -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() diff --git a/packages/api/session-controller/tests/lineage.client.spec.ts b/packages/api/session-controller/tests/lineage.client.spec.ts index b15b89e61b..27080a8b2f 100644 --- a/packages/api/session-controller/tests/lineage.client.spec.ts +++ b/packages/api/session-controller/tests/lineage.client.spec.ts @@ -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') }) }) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index c33a4a28c0..5cc6145b34 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -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>>() 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>>() const second = Promise.withResolvers>>() 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>>() const child = Promise.withResolvers>>() 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>>() + 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>>() - 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>>() - 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') }) }) diff --git a/packages/api/session-controller/tests/reference-ownership.client.spec.ts b/packages/api/session-controller/tests/reference-ownership.client.spec.ts new file mode 100644 index 0000000000..c1d0f74065 --- /dev/null +++ b/packages/api/session-controller/tests/reference-ownership.client.spec.ts @@ -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, 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 => { + 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>() + const entered = Promise.withResolvers() + 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>() + 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() + 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() + const resume = Promise.withResolvers() + 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() + const response = Promise.withResolvers() + 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() + }) +}) diff --git a/packages/api/session-controller/tests/scope.client.spec.ts b/packages/api/session-controller/tests/scope.client.spec.ts index a0d7be3e7e..0573da7c06 100644 --- a/packages/api/session-controller/tests/scope.client.spec.ts +++ b/packages/api/session-controller/tests/scope.client.spec.ts @@ -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')) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 9607c670f0..42ee4b94ff 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -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>() 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"}') diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index e1acd28966..730951d78b 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -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>() const aborted = new Set() 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() + 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() - 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() - 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([ - ['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>>() 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() - }) - }) diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index d65a1c82f0..cb4a978ab0 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -20,7 +20,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] function emptySessions() { const store = createSnapshotStore( - { 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[0]>[0] +type AttentionSnapshot = Parameters[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, diff --git a/packages/client/resources/tests/apply.client.spec.ts b/packages/client/resources/tests/apply.client.spec.ts index 6431d0485f..a474d6fdf0 100644 --- a/packages/client/resources/tests/apply.client.spec.ts +++ b/packages/client/resources/tests/apply.client.spec.ts @@ -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() const open = vi.fn['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) diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 261ef73f52..1f0c6bd36a 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -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 }) { const listeners = new Set<() => void>() + const bindings = new Map() + 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 + } = { + 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 = { diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index 0fc6fc5d94..634383621e 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -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 = (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 { const template = en[key] @@ -49,12 +53,17 @@ function translate(key: keyof typeof en, params?: Record): stri function renderSeat( state: Partial = {}, select: () => Promise = () => Promise.resolve(undefined), + session?: { id: string; retainInfo: SessionRetainInfo | undefined }, ) { const store = createSnapshotStore({ ...SEAT_READY, ...state }) const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(select), introduced: vi.fn() } render((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() diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 3e86094c74..3d3711a77a 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -34,7 +34,7 @@ const attachment = { name: 'history.png', } -type AttentionSnapshot = Parameters[0]>[0] +type AttentionSnapshot = Parameters[0]>[0] type TrajectorySnapshot = Parameters[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, diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index b0a7c372ff..3802528471 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -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( () => 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() diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index ac364e4cb7..051b78e01d 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -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 => { const contribution = b.sourceDescriptor.resolve(owner) as { hooks: { chat: ObservableSnapshot } } 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 expect(resolveSource(binding)).toBe(source) expect(resolveSource(binding)).toBe(source) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 55bf2dd537..8a63203668 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -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 = {}): CompactionSummar /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { const store = createSnapshotStore( - { 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(new Map()), + useSessionStatus: bindSnapshotSelector( + createSnapshotStore(new Map()), ), useWorkspaces: emptyWorkspaces(), useProjection: (key: string) => { diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index 288bdce31d..0c544c1211 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -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({ - 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(new Map())) + return bindSnapshotSelector(createSnapshotStore(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, diff --git a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts index 6d57504375..a53a161b14 100644 --- a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts @@ -29,9 +29,16 @@ async function bench() { }, }) const scopes = new Map() + const bindings = new Map() 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 } diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 87d87311d7..9dab8a0307 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -107,9 +107,12 @@ async function bench(opts: BenchOptions = {}) { }) // Real scope tags behind a fake sessions face. const scopes = new Map } }>() + const bindings = new Map() 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 () => { diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 678985ba88..fa303b1241 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -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() + 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() }) diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index 3c59deb1c2..cef480ee69 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -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 } diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx index 573a732780..39f656a5ac 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx @@ -50,6 +50,23 @@ const LAYOUT_CHILDREN = { 'main': { kind: 'keyed', scope: 'root' }, } as const +function provideWorkspaceNavigation(runtime: SlotTestRuntime): (id: SessionId) => void { + let mainReference: ReturnType | 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(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() }, }) + openSession(SID) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...inject], apply }) const view = runtime.renderRoot() diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 22ca9a0fb3..d063279939 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -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({ 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 { diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 66011fc2b0..8ddb46f500 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -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({ - 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(new Map())) + return bindSnapshotSelector(createSnapshotStore(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), diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index 54be8975c3..bdf7c0f0f1 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -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>>() 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() diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index e10ff1ddaf..6d3f4dfc88 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -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({ - 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, diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index bf9bcd3940..79acf88d3f 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -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(new Map()), + useSessionStatus: bindSnapshotSelector( + createSnapshotStore(new Map()), ), + useSessionRetainInfo: () => undefined, useResource, useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 9f8c9f6bf5..dee63d6dd7 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -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(new Map()), + useSessionStatus: bindSnapshotSelector( + createSnapshotStore(new Map()), ), + useSessionRetainInfo: () => undefined, useResource, useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 46605029c7..f59a003339 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -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 t, usePanelInfo, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, + useSessionRetainInfo: () => undefined, useResource, - useSessionPendingInteraction: bindSnapshotSelector( - createSnapshotStore(new Map()), + useSessionStatus: bindSnapshotSelector( + createSnapshotStore(new Map()), ), useWorkspaces: (() => { throw new Error('unused') }) as never, useProjection: (() => undefined) as never, diff --git a/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx index 403d0a072a..a463704860 100644 --- a/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx +++ b/packages/client/ui-conversation/tests/selection-survival.client.spec.tsx @@ -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 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>, slot: 'conversation.session' | 'conversation.session.header', - sessionId: SessionId, + reference: ReturnType, ): 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() }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 75f65ffdee..280f684945 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -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() 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 } diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 7d7ba8b366..71f238c4d9 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -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(workspaceState(workspaceRows)) const session = createSnapshotStore(snapshot) const useSession = bindSnapshotSelector(session) const conversation = createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT) const useConversation = bindSnapshotSelector(conversation) - const useSessionPendingInteraction = bindSnapshotSelector( - createSnapshotStore(new Map()), + const useSessionStatus = bindSnapshotSelector( + createSnapshotStore(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), diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 36ae495709..cedb8a2d04 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -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: (select: (state: SessionListState) => T): T => select(sessions), reloadPresentedHost: vi.fn(() => controller.loadHost()), diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index 6b9160c6bf..3e31b2f2f5 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -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 diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index d42a30d65c..93602bf55c 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -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() 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 () => { diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index c3f016d58e..30ef0c7040 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -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(select: (snapshot: SessionListState) => T): T { return select(state) diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index 2e525ae028..082dba8dcd 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -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[0]>[0] +type AttentionSnapshot = Parameters[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} diff --git a/packages/client/ui-layout/tests/document-title.client.spec.tsx b/packages/client/ui-layout/tests/document-title.client.spec.tsx index 5c3184970f..eb77ab430c 100644 --- a/packages/client/ui-layout/tests/document-title.client.spec.tsx +++ b/packages/client/ui-layout/tests/document-title.client.spec.tsx @@ -18,12 +18,10 @@ function titleSources() { const sessionId = 'session-title' as SessionId const sessions = createSnapshotStore({ 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({ 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') }) diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index ad821731f1..47dfb1e5c5 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -134,20 +134,15 @@ async function bench(locale: 'zh' | 'en' = 'zh') { localeRuntime.setLocale(locale) ctx.provide('locale', localeRuntime) const scopes = new Map() + const bindings = new Map SnapshotStore } } + ctx: Context + }>() const addressed = new Set() 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({ + const projection = createSnapshotStore({ 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 { diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index b1af331463..c5f3cd4d79 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -55,13 +55,13 @@ function ok(value: T) { const dictionary: Record = zh const t: PermissionRowProps['t'] = key => dictionary[key] ?? key -type AttentionSnapshot = Parameters[0]>[0] +type AttentionSnapshot = Parameters[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, } diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 7c36c229c5..af7da41774 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -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() + 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) diff --git a/packages/client/ui-renderer/tests/registry.client.spec.ts b/packages/client/ui-renderer/tests/registry.client.spec.ts index 627737539b..d7c8a296a4 100644 --- a/packages/client/ui-renderer/tests/registry.client.spec.ts +++ b/packages/client/ui-renderer/tests/registry.client.spec.ts @@ -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) diff --git a/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx b/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx index d0887241c1..e9199e3f6a 100644 --- a/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx +++ b/packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx @@ -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), diff --git a/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx b/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx index 1052bc0105..3761098545 100644 --- a/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx +++ b/packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx @@ -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(initial: T) { * ledger; session bindings are identity-stable per id. */ function makeHost() { - const scopeCtx = new Context() const entries = new Map() const specs = new Map() const versions = new Map() @@ -102,9 +103,11 @@ function makeHost() { keyedHooks: {}, props: { sessionId: undefined }, } + const absent = observable(absentBinding) const currentBinding = observable(absentBinding) + const targets = new Map() + const bindingSources = new Map>>() let currentId: string | undefined - const bindings = new Map() const sessionSources = new Map>>() const root = observable({ 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(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(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, body: (renderSlotChain: RenderSlotChainFn) => ReactNode) { +function mountChainRoot( + h: Fake, + children: Record, + 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: () => never, select })) let fallbackOnly = true const { view } = mountChainRoot(h, { 'k.chain': CHAIN_SESSION }, - renderSlotChain => renderSlotChain( - 'k.chain', - {}, - { fallback: , fallbackOnly, overlay: true }, - )) + (renderSlotChain, Provider) => { + const content = renderSlotChain( + 'k.chain', + {}, + { fallback: , fallbackOnly, overlay: true }, + ) + return content}>{content} + }) 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 }) => {sessionId} }) 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 ( - empty}> + empty}> {renderSlot('k.session', {})} ) @@ -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 {`${sessionId ?? 'blank'}#${mount}`} }, }) - 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(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') diff --git a/packages/client/ui-renderer/tests/session-provider.client.spec.tsx b/packages/client/ui-renderer/tests/session-provider.client.spec.tsx index 8f9398d17b..2cea4d06bb 100644 --- a/packages/client/ui-renderer/tests/session-provider.client.spec.tsx +++ b/packages/client/ui-renderer/tests/session-provider.client.spec.tsx @@ -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(initial: T) { let value = initial @@ -23,216 +30,238 @@ function observable(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(absentBinding) - let currentId: string | undefined - const bindings = new Map() + const absent = observable(absentBinding) + const selected = observable(undefined) + const references = new Map() + const sources = new Map>>() const sessionEntries: StoredEntry[] = [] - const root = observable({ - key: undefined, - hooks: {}, - keyedHooks: {}, - props: {}, + const root = observable({ 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} - : {children}, - }), + 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(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 +} + 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) => ( - empty}> -
session
-
- ), - }) + it('renders explicit absence and switches only when the owner supplies a reference', () => { + const h = makeHost((renderSlot, Provider, reference) => + empty}>{renderSlot('k.session', {})}) + h.registerSession({ component: ({ sessionId }: { sessionId: string }) => {sessionId}, 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) => session, - }) + it('renders nothing for explicit absence when the empty branch is omitted', () => { + const h = makeHost((_renderSlot, Provider) => session) 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
{id}
- } - const h = makeHost({ - root: (renderSlot, SessionProvider) => ( - {renderSlot('k.session', {})} - ), - }) - h.registerSession({ - component: (props: { sessionId?: string }) => , - 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 }) => {sessionId ?? 'absent'}, 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[] = [] - const h = makeHost({ - root: (renderSlot, SessionProvider) => ( - {renderSlot('k.session', {})} - ), + it('preserves a resident optional subtree when absence adopts its first Session', () => { + const h = makeHost((renderSlot, Provider, reference) => { + const body = renderSlot('k.session', {}) + return body}>{body} + }, { optional: true }) + h.registerSession({ + component: ({ sessionId }: { sessionId?: string }) => , + 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 body}>{body} + }, { 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) => + {renderSlot('k.session', {})}) + h.registerSession({ component: Counter, options: {} }) h.addSession('s1') h.addSession('s2') - h.registerSession({ - component: (props: { useSession?: (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) => ( - {renderSlot('k.session', {})} - ), - }) - const original = h.addSession('s1') + it('refreshes descriptor props without remounting the live generation', () => { + const h = makeHost((renderSlot, Provider, reference) => + {renderSlot('k.session', {})}) + const session = h.addSession('s1') h.registerSession({ - component: (props: { feature?: string }) => { - seen.push(props.feature) - return null - }, + component: ({ sessionId, feature }: { sessionId: string; feature?: string }) => + <>{feature}, 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) => ( + + {renderSlot('k.session', { position: 'outer-before' })} + {renderSlot('k.session', { position: 'inner' })} + nested-empty}>hidden + {renderSlot('k.session', { position: 'outer-after' })} + + )) + const outer = h.addSession('a') + const nested = h.addSession('b') + h.current.set('a') + h.registerSession({ + component: ({ sessionId, position }: { sessionId: string; position: string }) => {position}:{sessionId};, + 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) => + {renderSlot('k.session', {})}, { optional: true }) + h.addSession('optional') + h.current.set('optional') + h.registerSession({ component: ({ sessionId }: { sessionId?: string }) => {sessionId}, 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() }) }) diff --git a/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx b/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx index bfc9ec1a85..8e43e0b55a 100644 --- a/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx +++ b/packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx @@ -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) diff --git a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx index ef254bff8d..e688fad1da 100644 --- a/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx +++ b/packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx @@ -37,7 +37,7 @@ async function bench() { } const adapter: SlotScopeAdapter = { current, - resolve: () => undefined, + bindingSource: () => current, } slots.installScope('session', adapter) return { ctx, slots, fiber } diff --git a/packages/client/ui-renderer/tests/use-projection.client.spec.tsx b/packages/client/ui-renderer/tests/use-projection.client.spec.tsx index a34d139a28..f004d30914 100644 --- a/packages/client/ui-renderer/tests/use-projection.client.spec.tsx +++ b/packages/client/ui-renderer/tests/use-projection.client.spec.tsx @@ -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(absentBinding) + const absentBindingSource = observable(absentBinding) + const selected = observable(undefined) + const bindingSources = new Map>>() const cells = new Map>>() /** Store-parallel source family: an unseen key snapshots undefined. */ const absent = { getSnapshot: () => undefined, subscribe: () => () => {} } const sessionEntries: StoredEntry[] = [] const bindings = new Map() 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.renderSlot('k.session', {})} + }, 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(value)) return value } const root = observable({ @@ -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) }, diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 9560fdbf5e..bce5fdadcc 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -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> readonly resolveBinding: ReturnType SessionBinding | undefined>> readonly createSession: ReturnType> - readonly openSession: ReturnType void>> - readonly clearSession: ReturnType void>> + readonly retainInfo: ReturnType> 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 } 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({ ids: [], byId: {}, - current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, - currentAddress: undefined, }) const bindings = new Map() const scopes = new Map() + const retention = new Map>>() + const retainSource = (id: SessionId): ReturnType> => { + let source = retention.get(id) + if (source === undefined) { + source = createSnapshotStore({ referenceCount: 0, retainedBy: {} }) + retention.set(id, source) + } + return source + } const resolveBinding = vi.fn((id: SessionId) => bindings.get(id)) const createSession = vi.fn(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(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({ 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 => 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( () => 0, ) @@ -444,26 +610,26 @@ describe('UiSession pending interactions', () => { const background = { key: 'background:1', kind: 'background', sessionId: id } const delegate = (): Promise => 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', () => { diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index c9cb8eadad..f7a012dade 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -30,10 +30,13 @@ const t: TriggerContentProps['t'] = key => (en as Record)[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[0]>[0] +type AttentionSnapshot = Parameters[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', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index 9a83b4274d..6c0ec8c19c 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -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 = { 'settings.close': 'Close', } -type AttentionSnapshot = Parameters[0]>[0] +type AttentionSnapshot = Parameters[0]>[0] type ConnectionSnapshot = Parameters[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 diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index f4a05c9363..392497d9b9 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -45,9 +45,9 @@ const DeepSeekConfig = Schema.object({ })), }) -type AttentionSnapshot = Parameters[0]>[0] +type AttentionSnapshot = Parameters[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), diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 41abead4e8..07f7ceac21 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -52,9 +52,9 @@ function welcomeView(value: unknown, revision = 0) { } } -type AttentionSnapshot = Parameters[0]>[0] +type AttentionSnapshot = Parameters[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), diff --git a/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx b/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx index 6e947e912b..a944d8dea9 100644 --- a/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-unarchive-sessions/tests/components.client.spec.tsx @@ -20,18 +20,16 @@ const t = ((key: ArchivedSessionsLocaleKey, params?: Record session.id), byId: Object.fromEntries(sessions.map(session => [session.id, session])), - current: undefined, phase, subagentsByParent: {}, jobsBySession: {}, - currentAddress: undefined, } } diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index adc99e5c51..cba4157aa4 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -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().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 }) { diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx index 582fdb8220..4e328a1977 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx @@ -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(() => { 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: })) + }, ({ renderSlot, SessionProvider }) => ( + + {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: , + })} + + )) const view = runtime.renderRoot() expect(view.getByRole('heading', { name: 'Notes' })).toBeDefined() expect(view.getByRole('button', { name: 'Copy' })).toBeDefined() diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index d493ed88ce..8a5db55190 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -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() const titles = new Map() @@ -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['create']> + const instance = runtime.storeOf('rightbar.session', reference) as ReturnType['create']> const controller = runtime.ctx.sidebarRight const layout = () => instance.getSnapshot().bySession[SESSION]!.layout const open = (name = 'a.txt', options?: Parameters[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() diff --git a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx index fb1f61aab4..ec7bcd46ca 100644 --- a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx @@ -13,7 +13,7 @@ afterEach(cleanup) type CleanupProps = Parameters[0] const unusedHook = (): never => { throw new Error('Terminal cleanup does not consume global hooks') } const standard: Omit = { - useSessions: unusedHook, useSessionPendingInteraction: unusedHook, usePanelInfo: unusedHook, + useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, usePanelInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, } diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index e010a3385f..00e6cb36fb 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -23,9 +23,9 @@ const COLUMN_HEIGHT = 600 const t: SidebarRootComponentProps['t'] = key => (en as Record)[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[0]>[0] +type AttentionSnapshot = Parameters[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( {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index 67da6f3fd3..4d8b3438ab 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -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[0]>[0] +type AttentionSnapshot = Parameters[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 = () => ( {}} 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( {}} 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( {}} 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( {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 015b42c54d..68408e7b79 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -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] }, ]) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index e026945747..1474b4fe5a 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -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(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, } diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index 5ad602ff05..60a830a76b 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -26,7 +26,7 @@ const COPY: Record = { function emptySessions() { const store = createSnapshotStore( - { 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[0]>[0] +type AttentionSnapshot = Parameters[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, diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index c917901cab..7ec72aa242 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -28,7 +28,7 @@ const COPY: Record = { /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { 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[0]>[0] +type AttentionSnapshot = Parameters[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, diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 7959b9c60a..4b48f88c0b 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -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(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 }) diff --git a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx index 94db65688d..8c888dd667 100644 --- a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx @@ -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 }) diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 04e464d968..7c2a3bf265 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -25,12 +25,10 @@ function listStore() { return createSnapshotStore({ 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, }) } diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index 1e8b2fdef6..9f5fabeede 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -200,11 +200,9 @@ describe('chat row diff body', () => { describe('FileMutationRow diff card', () => { const list = () => createSnapshotStore({ 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 => ({ diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 21ace19335..91d25b8740 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -204,11 +204,9 @@ describe('GenericToolCard read body', () => { describe('ReadRow keyed toolview', () => { const list = () => createSnapshotStore({ 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[0] => ({ diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 11a9ebe63e..1df6ac1d3e 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -405,11 +405,9 @@ describe('chat row terminal body', () => { describe('BashRow terminal card', () => { const list = () => createSnapshotStore({ 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 => ({ diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 4b15653b82..c13ac0965a 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -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(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) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 58d786293d..e1707c6aee 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -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( - { 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(new Map()), + useSessionStatus: bindSnapshotSelector( + createSnapshotStore(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(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>) { - 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(session) const useTrajectory = bindSnapshotSelector(trajectoryStore) const useConversation = bindSnapshotSelector(conversationStore) const useChat = bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)) const useSessions = emptySessions() - const useSessionPendingInteraction = bindSnapshotSelector( - createSnapshotStore(new Map()), + const useSessionStatus = bindSnapshotSelector( + createSnapshotStore(new Map()), ) const useWorkspaces = emptyWorkspaces() const conversation = createConversationStore().create() @@ -352,7 +352,8 @@ function mount(fixture: Awaited>) { 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 => { const contribution = b.sourceDescriptor.resolve(owner) as { hooks: { trajectory: ObservableSnapshot } } 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 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) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 62146d7130..71b385e0fb 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -29,7 +29,7 @@ type ConversationState = Parameters[0]>[0] type TrajectoryState = Parameters[0]>[0] type InputState = Parameters[0]>[0] -type AttentionState = Parameters[0]>[0] +type AttentionState = Parameters[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 = { 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), diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 2570efe7cd..9dff625620 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -28,7 +28,7 @@ type ConversationState = Parameters[0]>[0] type TrajectoryState = Parameters[0]>[0] type InputState = Parameters[0]>[0] -type AttentionState = Parameters[0]>[0] +type AttentionState = Parameters[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 = 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), diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 4ef71e2a10..6c999db2b6 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -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 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() 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([]) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 101dd0d266..9e53d52b1b 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -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') diff --git a/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx index 0dd5eecea9..45f7f2c594 100644 --- a/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx +++ b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx @@ -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) diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index b814e39803..e8bcc71a79 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -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 { // 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', diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index b19d4c7db8..4ce41c80ac 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -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 = new Map() +const noAttention: SessionStatusSnapshot = new Map() +const status = ( + pendingInteraction: SessionPendingInteraction | undefined, + overrides: Partial = {}, +): 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 = 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 = 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( diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index d7b1168f50..e372891f5c 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -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 => ({ id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides, + retainedBy: overrides.retainedBy ?? {}, }) -const sessionState = (items: readonly SessionSummary[], overrides: Partial = {}): 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 & { 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(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } } @@ -79,7 +91,8 @@ function mount(overrides: Partial = {}) { 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) diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index 3783a1d16a..e3c3437d58 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -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(snapshot: T) { return function select(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( 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( undefined} usePanelInfo={usePanelInfo} useResource={useResource} onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t} diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 3cd4995413..7f910423f9 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -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 { + 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 = {}): 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 { } } -class FakeSessions { +interface RetainedSession { + readonly reference: SessionReference + readonly release: ReturnType void>> +} + +class FakeSessions implements ISessions { readonly list: MutableSource readonly create: ReturnType> - readonly open: ReturnType void>> - readonly clear: ReturnType void>> readonly fork = vi.fn(async () => sid('forked')) + readonly retained: RetainedSession[] = [] + readonly refreshSubagents = vi.fn(() => Promise.resolve()) + readonly retain = vi.fn((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() + 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(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 { - 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() - 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() - const newer = Promise.withResolvers() - 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() - 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() + const second = Promise.withResolvers() + 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() - 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() - 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() + 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() - 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() + 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() + 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() + 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() - 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() - 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() - 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() 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 () => { diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index 64a798f998..b1e473d518 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -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', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index ee7ba43e7e..4af6be17b9 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -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', 'forkSession(sessionId: SessionId): Promise', 'connectWorkspace(workspaceId: WorkspaceId): Promise', diff --git a/packages/test-support/client-runtime/src/fixtures.ts b/packages/test-support/client-runtime/src/fixtures.ts index 934a0215e3..8e39f3c112 100644 --- a/packages/test-support/client-runtime/src/fixtures.ts +++ b/packages/test-support/client-runtime/src/fixtures.ts @@ -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 } /** diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 34614b0205..66fd9fb764 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -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 { * 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): void + update(owner: OwnerOf, 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() + private readonly owners = new Map() 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 { 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 + 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(key: K, owner: OwnerOf, opts?: RenderOpts): SlotView { + renderSlot( + key: K, + owner: OwnerOf, + opts?: SlotTestRenderOptions, + ): SlotView { 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() } } diff --git a/packages/test-support/client-runtime/src/remote.ts b/packages/test-support/client-runtime/src/remote.ts index 34737e2295..66e66770dd 100644 --- a/packages/test-support/client-runtime/src/remote.ts +++ b/packages/test-support/client-runtime/src/remote.ts @@ -38,17 +38,34 @@ export class TestRemote { * @param ctx - the spec's root Context. * @param namespaces - scripted namespace faces reached as `ctx.remote.`. */ - constructor(ctx: Context, namespaces: Readonly> = {}) { + constructor(private readonly ctx: Context, namespaces: Readonly> = {}) { + 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.`. + */ + provideNamespaces(namespaces: Readonly>): void { + this.validateNamespaces(namespaces) + this.installNamespaces(namespaces) + } + + private validateNamespaces(namespaces: Readonly>): 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>): 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) } /** diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index bd668515b8..349c1a0f4c 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -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 session: FixtureSession - scope: AgentContext | undefined - scopeFiber: { dispose(): Promise } | undefined - binding: SessionBinding | undefined + overrides: Record + projections: Map + initialOpen: SessionFixture['initialOpen'] } +interface SessionGeneration { + readonly binding: SessionBinding + readonly snapshot: SnapshotStore + readonly session: FixtureSession + readonly fiber: { dispose(): Promise } + readonly lifetime: AbortController + readonly opening: Promise + retention: SessionRetainInfo + live: boolean +} + +interface OpeningControl { + readonly promise: Promise + readonly resolve: () => void + readonly reject: (error: unknown) => void +} + +function freezeRetainedBy( + counts: Partial>, +): 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, signal?: AbortSignal): Promise { + /* v8 ignore next -- TestSessionReference always supplies its release-composed signal. */ + if (signal === undefined) return opening + const aborted = Promise.withResolvers() + 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() + 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, 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 private readonly records = new Map() + private readonly generations = new Map() + private readonly addresses = new Map() + private readonly retentionStores = new Map>() + private readonly pendingDrops = new Set>() + 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({ - 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 { + async add(fixture: SessionFixture): Promise { 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({ ...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 { 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 { 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 { - 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 { - 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 { - 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>): Promise { 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 { - 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 { - 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( + target: SessionTarget, + options: SessionRetainOptions, + operation: (reference: SessionReference) => T | Promise, + ): Promise { + const reference = this.retain(target, options) + try { + await reference.ready + return await operation(reference) + } finally { + reference.release() + } + } + /* jscpd:ignore-end */ + + retainInfo(id: SessionId): ObservableSnapshot { + 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[0]): Promise { 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 { 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 { - 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 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 { + while (this.pendingDrops.size !== 0) await Promise.all([...this.pendingDrops]) } private require(id: string): SessionRecord { diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index f96849d9e4..6fe307748b 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -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') }) }) diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index cd4e8d8dd3..94f9a31d1b 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -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: no panel })} - no session}> + no session}> {renderSlot('trt.chat', {})} {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(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 + }) 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(undefined) + const runtime = await runtimeWithFrame(reference) runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => { const running = props.useSession(s => s.running) return chat:{props.sessionId}:{String(running)} @@ -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() + 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() + 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 + }).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(undefined) + const runtime = await runtimeWithFrame(reference) const handle = createSuiteStore() runtime.slots.register( { name: 'trt.chat', store: handle }, @@ -230,17 +425,23 @@ describe('stores', () => { note:{props.useStore(s => s.note)}) 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(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 }) => {sessionId ?? 'no session'}, + ) + + 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) => {sessionId}) + runtime.slots.register({ name: 'trt.other-chat' }, ({ sessionId }: SessionStandardProps) => {sessionId}) + 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(undefined) + const runtime = await runtimeWithFrame(reference) runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => ( todos:{props.useProjection('todos', value => value?.length ?? 0)} )) 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 () => { diff --git a/snapshots/web/details-session-lifecycle/sidebar.expected.md b/snapshots/web/details-session-lifecycle/sidebar.expected.md index dcee73dcfb..83ddf0bad8 100644 --- a/snapshots/web/details-session-lifecycle/sidebar.expected.md +++ b/snapshots/web/details-session-lifecycle/sidebar.expected.md @@ -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,