feat(web): add workspace-aware session flow

This commit is contained in:
imccyu
2026-07-25 16:04:48 +08:00
parent 755e2a8c51
commit 9eb9c70a8a
170 changed files with 7573 additions and 3006 deletions

View File

@@ -1,5 +1,5 @@
/**
* Runtime plugin browser-half apply: slots + sessions mounting over the
* Runtime plugin browser-half apply: slots + object services mounting over the
* connection handle, stream-loop sink wiring into the object layer, and the
* fiber-scoped loop teardown.
*/
@@ -34,14 +34,17 @@ async function mount(): Promise<Bench> {
}
describe('runtime client apply', () => {
it('mounts ctx.slots + ctx.sessions and wires the stream sinks into the manager', async () => {
it('mounts slots, Sessions, and Workspaces and fans host frames into both managers', async () => {
const bench = await mount()
expect(bench.ctx.get('slots') !== undefined).toBe(true)
// The built-in 'root' declaration ships with this package's SlotsService
// (the SlotMap 'root' merge lives here since the slot-parity rework).
expect(bench.ctx.slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
const sessions = bench.ctx.get('sessions')
const workspaces = bench.ctx.get('workspaces')
expect(sessions !== undefined).toBe(true)
expect(workspaces !== undefined).toBe(true)
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
expect(bench.sinks).toBeDefined()
// Frame sinks reach the object layer: a host session-added lands in the list store.
@@ -51,6 +54,18 @@ describe('runtime client apply', () => {
})
await Promise.resolve()
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
bench.sinks?.onHostEnvelope?.({
rpcId: 'r-workspace' as never,
payload: {
type: 'host/workspace-changed',
workspace: {
workspaceId: 'w-new', path: '/w/new', title: 'new', sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
},
} as never,
})
await Promise.resolve()
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onConnected?.()

View File

@@ -3,9 +3,23 @@
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
/** Programmable-default workspace row (branded id, ISO-ish times). */
function fakeWorkspace(id: string, over: Partial<WorkspaceView> = {}): WorkspaceView {
return {
workspaceId: id as WorkspaceId,
path: '/f/ws',
title: 'ws',
sessionIds: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...over,
}
}
export interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
@@ -74,6 +88,15 @@ export class FakeApiClient implements IApiClient {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
}
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false

View File

@@ -13,7 +13,7 @@ const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
})
describe('flattenLineage', () => {
it('sorts roots by updatedAt desc and expands children DFS with depth, children sorted too', () => {
it('keeps established root and sibling order while expanding children DFS with depth', () => {
const out = flattenLineage([
s('old-root', 10),
s('new-root', 30),
@@ -22,7 +22,7 @@ describe('flattenLineage', () => {
s('grandkid', 5, 'kid-new'),
])
expect(out.map(e => [e.sessionId, e.depth])).toEqual([
['new-root', 0], ['kid-new', 1], ['grandkid', 2], ['kid-old', 1], ['old-root', 0],
['old-root', 0], ['new-root', 0], ['kid-old', 1], ['kid-new', 1], ['grandkid', 2],
])
})

View File

@@ -57,7 +57,7 @@ describe('instances', () => {
})
describe('list lifecycle', () => {
it('single-flights refreshList and lands items sorted through lineage flattening', async () => {
it('single-flights refreshList and preserves the Host baseline order', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => gate.promise
@@ -65,12 +65,33 @@ describe('list lifecycle', () => {
const first = manager.refreshList()
const second = manager.refreshList()
expect(manager.getListSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] }))
await Promise.all([first, second])
expect(api.callsOf('session.list')).toHaveLength(1)
const snapshot = manager.getListSnapshot()
expect(snapshot.state).toBe('idle')
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) // updatedAt desc
expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1])
})
it('replays incremental frames over hydration and never batch-reorders established ids', async () => {
const api = new FakeApiClient()
const first = deferred<Awaited<ReturnType<FakeApiClient['onList']>>>()
api.onList = () => first.promise
const manager = new SessionManager(api)
const hydration = manager.refreshList()
manager.handleHostEnvelope({
rpcId: 'during-first' as never,
payload: { type: 'host/session-added', sessionId: S2 },
})
first.resolve(ok({ items: [summary(S1)] as never[] }))
await hydration
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
api.onList = () => Promise.resolve(ok({
items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[],
}))
await manager.refreshList()
expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1])
})
it('keeps the error in the list snapshot on failure', async () => {
@@ -79,6 +100,26 @@ describe('list lifecycle', () => {
const manager = new SessionManager(api)
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } })
// A failed pull does not step the arrival phase: still pending.
expect(manager.getListSnapshot().phase).toBe('pending')
})
it('phase steps pending → ready on the first successful pull and never returns', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
expect(manager.getListSnapshot().phase).toBe('pending')
await manager.refreshList()
expect(manager.getListSnapshot().phase).toBe('ready')
// Sticky across later failures: the pull-activity axis reports the error,
// the arrival phase holds.
api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' })
// And across an empty re-pull (empty-with-ready = truly no sessions).
api.onList = () => Promise.resolve(ok({ items: [] as never[] }))
await manager.refreshList()
expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' })
expect(manager.getListSnapshot().items).toEqual([])
})
it('merges create into the list immediately without waiting for a refresh', async () => {
@@ -192,14 +233,14 @@ describe('remaining branches', () => {
expect(session.getSnapshot().running).toBe(true)
})
it('create passes cwd through, folds transport throws, and skips the merge when already listed', async () => {
it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(ok({ sessionId: S1 }))
const manager = new SessionManager(api)
await manager.create('/tmp/w')
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w' }])
await manager.create({ cwd: '/tmp/w', sessionId: S1 })
expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }])
expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' })
await manager.create('/tmp/w') // same id returned: no duplicate row
await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row
expect(manager.getListSnapshot().items).toHaveLength(1)
api.onCreate = () => Promise.reject(new Error('create wire down'))
expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } })
@@ -208,6 +249,42 @@ describe('remaining branches', () => {
expect(await manager.create()).toMatchObject({ ok: false })
})
it('publishes a real Ungrouped summary from workspace-attach-failed', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'published but unattached',
details: { sessionId: S1, workspaceId: 'w1' },
} as never))
const manager = new SessionManager(api)
const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } })
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })])
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
it('reconciles a preallocated id after an ordinary transport failure', async () => {
const api = new FakeApiClient()
api.onCreate = () => Promise.reject(new Error('response lost'))
const manager = new SessionManager(api)
const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 })
expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } })
expect(manager.getListSnapshot().items).toEqual([])
manager.handleHostEnvelope({
rpcId: 'published-later' as never,
payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' },
})
expect(manager.getListSnapshot().items).toEqual([
expect.objectContaining({ sessionId: S1, cwd: '/w/one' }),
])
manager.handleHostEnvelope({
rpcId: 'duplicate-frame' as never,
payload: { type: 'host/session-added', sessionId: S1, cwd: '/w/one' },
})
expect(manager.getListSnapshot().items).toHaveLength(1)
})
it('subscribe notifies on list changes and stops after unsubscribe', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)

View File

@@ -0,0 +1,191 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
const wid = (id: string): WorkspaceId => id as WorkspaceId
function workspace(id: string, sessionIds: SessionId[] = []): WorkspaceView {
return {
workspaceId: wid(id),
path: `/w/${id}`,
title: id,
sessionIds,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
}
async function ready(
api: FakeApiClient,
workspaces: WorkspacesService,
sessions: SessionsService,
workspaceRows: WorkspaceView[],
sessionRows: { sessionId: SessionId; updatedAt: number; running: boolean }[] = [],
): Promise<void> {
api.onWorkspaceList = () => Promise.resolve(ok({ items: workspaceRows as never[] }))
api.onList = () => Promise.resolve(ok({ items: sessionRows as never[] }))
await Promise.all([workspaces.refresh(), sessions.refresh()])
await Promise.resolve()
}
function services(api: FakeApiClient): { sessions: SessionsService; workspaces: WorkspacesService } {
const ctx = new Context()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
return { sessions, workspaces }
}
function pendingPrompt(sessions: SessionsService, sessionId: SessionId) {
return sessions.binding(sessionId)?.session.getSnapshot().pendingPrompt
}
describe('frontend Session and Workspace intents', () => {
it('resolves the initial intent into the most recently active Workspace', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
const old = workspace('old', [sid('s-old')])
const recent = workspace('recent', [sid('s-recent')])
await ready(api, workspaces, sessions, [old, recent], [
{ sessionId: sid('s-old'), updatedAt: 1, running: false },
{ sessionId: sid('s-recent'), updatedAt: 2, running: false },
])
expect(sessions.list.getSnapshot().intent).toMatchObject({
target: { kind: 'workspace', workspaceId: 'recent' },
phase: 'ready',
})
expect(workspaces.list.getSnapshot().intent).toBeUndefined()
})
it('materializes zero-state Workspace and Session intents and retains a rejected first prompt', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
await ready(api, workspaces, sessions, [])
expect(workspaces.list.getSnapshot().intent).toMatchObject({ name: 'workspace', phase: 'ready' })
sessions.updateIntent('first prompt')
api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('created'), created: true }))
api.onCreate = payload => Promise.resolve(ok({
sessionId: (payload as { sessionId: SessionId }).sessionId,
}))
api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'prompt offline', details: {} }))
workspaces.sendSession()
await vi.waitFor(() => {
const sessionId = sessions.list.getSnapshot().current as SessionId
expect(pendingPrompt(sessions, sessionId)).toMatchObject({
text: 'first prompt', phase: 'failed', retry: 'send',
})
})
expect(api.callsOf('workspace.create')).toEqual([{ name: 'workspace' }])
const create = api.callsOf('session.create')[0] as { workspaceId: WorkspaceId; sessionId: SessionId }
expect(create.workspaceId).toBe('created')
expect(api.callsOf('session.prompt')).toEqual([{
sessionId: create.sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'first prompt' }],
}])
expect(workspaces.list.getSnapshot().intent).toBeUndefined()
})
it('turns Workspace attachment failure into a focused real Session and retries its prompt', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
const target = workspace('target')
await ready(api, workspaces, sessions, [target])
sessions.updateIntent('keep this')
api.onCreate = (payload) => {
const sessionId = (payload as { sessionId: SessionId }).sessionId
return Promise.resolve(err({
code: 'workspace-attach-failed',
message: 'attach rejected',
details: { sessionId, workspaceId: target.workspaceId },
}))
}
workspaces.sendSession()
await vi.waitFor(() => {
const snapshot = sessions.list.getSnapshot()
expect(snapshot.intent).toBeUndefined()
expect(pendingPrompt(sessions, snapshot.current as SessionId)).toMatchObject({
text: 'keep this', phase: 'failed', retry: 'connect',
})
})
const published = sessions.list.getSnapshot().current as SessionId
const session = sessions.binding(published)!.session
session.updatePendingPrompt('retry this')
api.onCreate = () => Promise.resolve(ok({ sessionId: published }))
session.retryPendingPrompt()
await vi.waitFor(() => {
expect(pendingPrompt(sessions, published)).toBeNull()
})
expect(api.callsOf('session.prompt').at(-1)).toMatchObject({
sessionId: published,
content: [{ type: 'text', text: 'retry this' }],
})
})
it('does not send after navigation while Session creation is in flight', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
const target = workspace('target')
await ready(api, workspaces, sessions, [target])
const gate = deferred<Awaited<ReturnType<FakeApiClient['onCreate']>>>()
api.onCreate = () => gate.promise
sessions.updateIntent('do not send yet')
workspaces.sendSession()
await vi.waitFor(() => { expect(api.callsOf('session.create')).toHaveLength(1) })
const requested = (api.callsOf('session.create')[0] as { sessionId: SessionId }).sessionId
workspaces.startSession(target.workspaceId)
const replacement = sessions.list.getSnapshot().intent!
gate.resolve(ok({ sessionId: requested }))
await vi.waitFor(() => {
expect(pendingPrompt(sessions, requested)).toMatchObject({
text: 'do not send yet', phase: 'failed', retry: 'send',
})
})
expect(api.callsOf('session.prompt')).toEqual([])
expect(sessions.list.getSnapshot()).toMatchObject({
current: replacement.sessionId,
intent: { sessionId: replacement.sessionId },
})
})
it('keeps a lost-response Intent and retries creation with its preallocated id', async () => {
const api = new FakeApiClient()
const { sessions, workspaces } = services(api)
const target = workspace('target')
await ready(api, workspaces, sessions, [target])
sessions.updateIntent('preserve me')
api.onCreate = () => Promise.reject(new Error('response lost'))
workspaces.sendSession()
await vi.waitFor(() => {
expect(sessions.list.getSnapshot().intent?.error).toMatchObject({ step: 'session' })
})
const requested = sessions.list.getSnapshot().intent?.sessionId as SessionId
sessions.handleHostEnvelope({
rpcId: 'published-later' as never,
payload: { type: 'host/session-added', sessionId: requested, cwd: target.path },
})
expect(sessions.list.getSnapshot()).toMatchObject({
current: requested,
intent: { sessionId: requested, error: { step: 'session' } },
})
expect(sessions.intent()?.getSnapshot().pendingPrompt).toMatchObject({
text: 'preserve me', phase: 'editing',
})
api.onCreate = payload => Promise.resolve(ok({
sessionId: (payload as { sessionId: SessionId }).sessionId,
}))
workspaces.sendSession()
await vi.waitFor(() => {
expect(api.callsOf('session.create')).toHaveLength(2)
expect(api.callsOf('session.prompt')).toHaveLength(1)
expect(sessions.list.getSnapshot()).toMatchObject({ current: requested, intent: undefined })
expect(pendingPrompt(sessions, requested)).toBeNull()
})
expect(api.callsOf('session.create').map(call => (call as { sessionId: SessionId }).sessionId))
.toEqual([requested, requested])
})
})

View File

@@ -217,19 +217,33 @@ describe('paging', () => {
})
describe('prompt and cancel errors', () => {
it('sends content through session.prompt with the mode passed through', async () => {
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {
const { api, session } = makeSession()
const result = await session.prompt([{ type: 'text', text: '要发的' }], 'queue')
// The blank → engaging edge fires before the RPC settles: the first-send
// flow reads the phase on the session area's first frame to keep the
// guidance hero from flashing back in.
expect(session.getSnapshot().composerPhase).toBe('blank')
const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue')
expect(session.getSnapshot().composerPhase).toBe('engaging')
const result = await inFlight
expect(result.ok).toBe(true)
// Monotone: settlement alone does not step the phase anywhere.
expect(session.getSnapshot().composerPhase).toBe('engaging')
expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }])
// First content lands (running turn): engaging → active.
session.handleRunning(true)
expect(session.getSnapshot().composerPhase).toBe('active')
})
it('business failure lands in promptError with op=send', async () => {
it('business failure lands in promptError with op=send; the phase stays engaging (retry, no hero bounce)', async () => {
const { api, session } = makeSession()
api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } }))
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue')
expect(result.ok).toBe(false)
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
// Failed first prompt: composer + error strip is the retry surface —
// blank is unreachable once a send was initiated.
expect(session.getSnapshot().composerPhase).toBe('engaging')
})
it('lands cancel failures in promptError with op=stop', async () => {

View File

@@ -9,7 +9,7 @@
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts'
import { FakeApiClient, ok } from './fake-api.ts'
const sid = (s: string): SessionId => s as SessionId
@@ -36,14 +36,14 @@ async function feedList(b: Bench, rows: { id: string; cwd?: string; parentId?: s
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
})),
}) as never)
await b.svc.manager.refreshList()
await b.svc.refresh()
await Promise.resolve() // manager notifier flush
}
describe('list store projection', () => {
it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => {
const b = bench()
b.svc.manager.handleMuxEnvelope({
b.svc.handleMuxEnvelope({
rpcId: 'title' as never,
payload: { type: 'session/title', sessionId: sid('s1'), title: 'Durable title', eventSeq: 2, updatedAt: 3 },
})
@@ -61,7 +61,7 @@ describe('list store projection', () => {
it('reflects live increments (host stream via manager) into the store', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.manager.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
b.svc.handleHostEnvelope({ rpcId: 'r1' as never, payload: { type: 'host/session-added', sessionId: sid('s2') } as never })
await Promise.resolve()
expect(b.svc.list.getSnapshot().ids).toContain('s2')
})
@@ -77,7 +77,7 @@ describe('scope tree', () => {
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
expect(binding?.session).toBe(b.svc.manager.get(sid('s1')))
expect(binding?.session).toBe(b.svc.cell('s1')?.session)
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
@@ -187,8 +187,8 @@ describe('cell (render-layer session kit)', () => {
const cell = b.svc.cell('s1')
expect(cell).toBeDefined()
expect(cell?.sessionId).toBe('s1')
// Hook binding happens in React; the cell carries the observable itself.
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
// The cell carries the observable; hook binding happens in React.
expect(cell?.session).toBe(b.svc.binding(sid('s1'))?.session)
expect(b.svc.cell('s1')).toBe(cell)
expect(b.svc.cell('ghost')).toBeUndefined()
})
@@ -284,36 +284,45 @@ describe('ancestry', () => {
})
describe('create', () => {
it('returns the new id on ok and throws a coded error on failure', async () => {
it('passes a preallocated id and preserves it on ordinary failure', async () => {
const b = bench()
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') }))
await expect(b.svc.create({ cwd: '/w' })).resolves.toBe('fresh')
await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh')
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }])
b.api.onCreate = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } },
} as never)
await expect(b.svc.create()).rejects.toThrow(/internal: 爆了/)
})
})
describe('createWorkspace', () => {
it('joins host.describe cwd with the name and creates there', async () => {
const b = bench()
b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 }))
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') }))
await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws')
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }])
const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(SessionCreateError)
expect(failure).toMatchObject({
requestedSessionId: 'candidate', publishedSessionId: undefined,
rpcError: { code: 'internal', message: '爆了' },
})
})
it('rejects empty names and path separators; surfaces describe failures', async () => {
it('surfaces the definitely published id after Workspace attachment fails', async () => {
const b = bench()
await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/)
await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/)
b.api.onDescribe = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } },
b.api.onCreate = () => Promise.resolve({
rpcId: 'attach' as never,
result: {
ok: false,
error: {
code: 'workspace-attach-failed', message: 'ledger unavailable',
details: { sessionId: sid('published'), workspaceId: 'ws' },
},
},
} as never)
await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/)
const failure = await b.svc.create({
workspaceId: 'ws' as never,
sessionId: sid('published'),
}).catch((error: unknown) => error)
await Promise.resolve()
expect(failure).toMatchObject({
publishedSessionId: 'published', requestedSessionId: 'published',
rpcError: { code: 'workspace-attach-failed' },
})
expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published' })
})
})

View File

@@ -85,11 +85,18 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost {
})
bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
bench.erased.renderSlot('root', {})
if (host === undefined) throw new Error('renderer never received the host')
return host
}
/** Minimal independent Workspace list source for the renderer host seam. */
function fakeWorkspaces() {
const state = { items: [], phase: 'ready' as const }
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
}
/** Minimal sessions face for the host seam (list observable + cell). */
function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined }
@@ -190,9 +197,18 @@ describe('renderer install seam', () => {
bench.erased.install({ renderRoot })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
expect(bench.erased.renderSlot('root', {})).toBe('tree')
expect(renderRoot).toHaveBeenCalledTimes(1)
})
it('fails before rendering when the Workspace object layer is absent', async () => {
const bench = await boot()
bench.erased.install({ renderRoot: () => null })
bench.erased.register({ name: 'root' }, C)
bench.ctx.reflect.provide('sessions', fakeSessions())
expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/)
})
})
describe('host face', () => {
@@ -220,6 +236,12 @@ describe('host face', () => {
expect(host.sessions.cell('known')).toMatchObject({ sessionId: 'known' })
expect(host.sessions.cell('ghost')).toBeUndefined()
})
it('exposes the independent Workspace list source', async () => {
const bench = await boot()
const host = captureHost(bench)
expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' })
})
})
describe('store instance axis', () => {
@@ -315,6 +337,7 @@ describe('entry-unload cascade', () => {
renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
})
bench.ctx.reflect.provide('sessions', fakeSessions())
bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
// The declarer here is NOT the root occupant: root stays occupied by a
// separate entry so disposing the declarer only kills its children.
const disposeRoot = bench.erased.register({ name: 'root' }, C)

View File

@@ -0,0 +1,157 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
const wid = (id: string): WorkspaceId => id as WorkspaceId
function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView {
return {
workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds,
createdAt, updatedAt: createdAt,
}
}
describe('WorkspaceManager', () => {
it('owns, materializes, retries, supersedes, and discards Workspace objects with local intents', async () => {
const api = new FakeApiClient()
const manager = new WorkspaceManager(api)
manager.startIntent('first')
expect(manager.getSnapshot().intent).toEqual({ name: 'first', phase: 'ready' })
api.onWorkspaceCreate = () => Promise.resolve(err({
code: 'workspace-name-conflict', message: 'taken', details: { name: 'first' },
} as never))
await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: false })
expect(manager.getSnapshot().intent).toMatchObject({ name: 'first', phase: 'ready' })
expect(typeof manager.getSnapshot().intent?.error).toBe('string')
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceCreate']>>>()
api.onWorkspaceCreate = () => gate.promise
const stale = manager.materializeIntent()
expect(manager.getSnapshot().intent?.phase).toBe('creating')
manager.startIntent('replacement')
gate.resolve(ok({ workspace: workspace('first'), created: true }))
await stale
expect(manager.getSnapshot().intent).toEqual({ name: 'replacement', phase: 'ready' })
api.onWorkspaceCreate = () => Promise.resolve(ok({ workspace: workspace('replacement'), created: true }))
await expect(manager.materializeIntent()).resolves.toMatchObject({ ok: true })
expect(manager.getSnapshot().intent).toBeUndefined()
await expect(manager.materializeIntent()).resolves.toBeUndefined()
manager.discardIntent()
manager.startIntent('discarded')
manager.discardIntent()
expect(manager.getSnapshot().intent).toBeUndefined()
})
it('replays changed frames over hydration and keeps established order on refresh', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const hydration = manager.refresh()
manager.handleHostEnvelope({
rpcId: 'changed' as never,
payload: { type: 'host/workspace-changed', workspace: workspace('new') },
})
gate.resolve(ok({ items: [workspace('old')] as never[] }))
await hydration
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle' })
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('old'), workspace('new')] as never[],
}))
await manager.refresh()
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old'])
})
it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()
api.onWorkspaceList = () => gate.promise
const manager = new WorkspaceManager(api)
const first = manager.refresh()
const second = manager.refresh()
expect(manager.getSnapshot().state).toBe('loading')
gate.resolve(ok({ items: [] }))
await Promise.all([first, second])
expect(api.callsOf('workspace.list')).toHaveLength(1)
api.onWorkspaceList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
await manager.refresh()
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'down' } })
api.onWorkspaceList = () => Promise.reject(new Error('wire down'))
await manager.refresh()
expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } })
})
it('creates by name/path, prepends a new row, and folds failures', async () => {
const api = new FakeApiClient()
const manager = new WorkspaceManager(api)
api.onWorkspaceCreate = payload => Promise.resolve(ok({
workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'),
created: true,
payload,
} as never))
await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true })
expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }])
expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created')
api.onWorkspaceCreate = () => Promise.reject(new Error('create transport'))
await expect(manager.create({ path: '/w/existing' })).resolves.toMatchObject({
ok: false, error: { code: 'internal', message: 'create transport' },
})
})
})
describe('WorkspacesService', () => {
it('feeds SessionManager readiness and recent-Workspace targeting without changing Host order', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
api.onWorkspaceList = () => Promise.resolve(ok({
items: [
workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
] as never[],
}))
await workspaces.refresh()
await Promise.resolve()
expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
api.onList = () => Promise.resolve(ok({
items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false }] as never[],
}))
await sessions.refresh()
await Promise.resolve()
await Promise.resolve()
expect(workspaces.list.getSnapshot()).toMatchObject({
baselinesReady: true,
recentWorkspaceId: 'active',
})
expect(sessions.list.getSnapshot().intent).toMatchObject({
target: { kind: 'workspace', workspaceId: 'active' },
})
expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
})
it('returns created Workspaces and preserves Host business errors', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' })
expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }])
api.onWorkspaceCreate = () => Promise.resolve(err({
code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
}))
await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/)
})
})