Merge remote-tracking branch 'origin/doc/host-client-group-readmes' into feat/directory-picker

This commit is contained in:
creatixchu
2026-07-29 01:09:33 +08:00
312 changed files with 8172 additions and 3465 deletions

View File

@@ -0,0 +1,56 @@
/**
* The outward session face. Feature packages never see the concrete Session
* class: components read conversation state through `useSession` (the
* ObservableSnapshot half), and orchestration code calls the behavior verbs
* below — nothing else. Widening this interface is the explicit act of
* widening what features may do to a session (and what every test fixture
* must stub); runtime-internal entry points (history staging, wire-frame
* dispatch) stay on the class, invisible out here.
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationSnapshot } from '../sessions/conversation.ts'
import type { ObservableSnapshot } from './store.ts'
/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */
export interface ProjectionsFace {
/**
* The identity-stable bare observable for one projection key (absence is
* an `undefined` snapshot, never a missing face).
* @param key - projection key.
* @returns the key's value face.
*/
faceOf(key: string): ObservableSnapshot<unknown>
}
/** Identity plus the behavior verbs features may invoke on a session. */
export interface ISession {
/** The session's host identity (agent id — same axis). */
readonly sessionId: SessionId
/** Host-computed projection values by key (the useProjection seat). */
readonly projections: ProjectionsFace
/**
* Send a prompt into the session.
* @param content - model-facing content blocks.
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
/**
* Cancel the running turn.
* @returns acceptance, or the business error.
*/
cancel(): Promise<RpcResult<{ accepted: true }>>
/**
* Extend the history window backwards (older messages pagination).
* @returns completion; failures land in snapshot.openState/loadingOlder.
*/
loadOlder(): Promise<void>
}
/**
* The full outward face: behavior verbs plus the conversation read side
* (the `useSession` hook source). This is the type carried by
* `SessionBinding.session` and the provide channel.
*/
export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>

View File

@@ -0,0 +1,47 @@
/**
* Cross-domain sessions face: the contract surface sibling domains (today:
* workspaces) consume instead of the sessions implementation. The sessions
* domain satisfies it structurally — SessionsService is assignable, checked
* wherever the assembly layer or a test injects the real service — so
* widening this face is the explicit act of widening the inter-domain
* dependency.
*/
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type { ObservableSnapshot } from './store.ts'
/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */
export interface SessionsPortSummary {
id: SessionId
/** Empty-log bit (blank sessions are reused by New Session instead of minting another). */
blank: boolean
cwd?: string
updatedAt: number
}
/** Session-list facts sibling domains read: readiness, selection, and the row map. */
export interface SessionsPortList {
ids: SessionId[]
byId: Record<SessionId, SessionsPortSummary>
current: SessionId | undefined
phase: 'pending' | 'ready'
}
/** The sessions-service face injected into sibling domains. */
export interface SessionsPort {
/** Observable list snapshot (read face only; writes stay inside the sessions domain). */
readonly list: ObservableSnapshot<SessionsPortList>
/**
* Create a session on the host.
* @param opts - target workspace.
* @returns the new session id.
*/
create(opts: { workspaceId: WorkspaceId }): Promise<SessionId>
/**
* Select a session as current.
* @param id - session id (must exist in the list store).
*/
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
}

View File

@@ -0,0 +1,64 @@
/**
* The outward sessions-service face — what `ctx.sessions` exposes to feature
* packages and the renderer host, and therefore exactly what the test
* runtime's sessions double must implement. Wire-pump entry points
* (handleMuxEnvelope/handleConnected/refresh) and runtime internals stay on
* the concrete class; cross-domain consumers keep the narrower
* [SessionsPort](./sessions-port.ts). Widening this interface is the
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type {
SessionBinding, SessionListState, SessionProvideDescriptor,
} from '../sessions/service.ts'
import type { SessionFace } from './session.ts'
import type { ObservableSnapshot } from './store.ts'
/** The sessions-service face injected as `ctx.sessions`. */
export interface ISessions {
/** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
readonly list: ObservableSnapshot<SessionListState>
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* Select a session as current.
* @param id - session id (must exist in the list; unknown ids fail loud).
*/
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Register a per-session standard-props provider (hooks become `use<Name>`
* selector hooks on the render side; props spread verbatim).
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider.
*/
provide(descriptor: SessionProvideDescriptor): () => void
/**
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id.
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined
/**
* Read the Agent scope tag off a context (service-method seam: fetch
* bundles must reach scope resolution through ctx.sessions).
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined
/**
* Resolve the session face behind an Agent-scoped context.
* @param ctx - an Agent-scoped context.
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
*/
sessionOf(ctx: Context): SessionFace | undefined
/**
* Resolve the stable session binding (scope-addressed assembly feed).
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined
}

View File

@@ -0,0 +1,65 @@
/**
* The outward workspaces-service face — what `ctx.workspaces` exposes to
* feature packages and the renderer host, and therefore exactly what the
* test runtime's workspaces double must implement. Wire-pump entry points
* (handleHostEnvelope/handleConnected/refresh/startInitialSelection) stay on
* the concrete class. Widening this interface is the explicit act of
* widening what features may do to the workspaces domain.
*/
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import type { WorkspaceListState } from '../workspaces/service.ts'
import type { ObservableSnapshot } from './store.ts'
/** The workspaces-service face injected as `ctx.workspaces`. */
export interface IWorkspaces {
/** The useWorkspaces standard feed (read face — writes stay inside the domain). */
readonly list: ObservableSnapshot<WorkspaceListState>
/**
* Connect a Workspace to its reusable or freshly created blank session.
* @param workspaceId - target workspace.
* @returns the connected session id.
*/
connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
/**
* The New Session flow: connect the target (or recent) Workspace and open
* the resulting session; failures surface on the session list state.
* @param workspaceId - explicit target; omitted uses the recency projection.
*/
startSession(workspaceId?: WorkspaceId): void
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* @returns the created or idempotently resolved Workspace.
*/
create(input: { name: string } | { path: string }): Promise<WorkspaceView>
/**
* Open the Host's native directory picker.
* @returns the selected path, or null when the user cancelled.
*/
pickDirectory(): Promise<string | null>
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.
*/
openPath(path: string): Promise<void>
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
* @param title - the new display title.
* @returns the updated Workspace view.
*/
rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>
/**
* Delete a Workspace (its sessions fall back to the unaccounted group).
* @param workspaceId - target workspace.
*/
delete(workspaceId: WorkspaceId): Promise<void>
/**
* Move an accounted session within/into a Workspace's ordered list.
* @param workspaceId - target workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the updated Workspace view.
*/
insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
}

View File

@@ -12,10 +12,17 @@ import type { UseProjection } from './sessions/projection-store.ts'
export { SlotsService } from './slots.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
// The provide channel is shared with the client test runtime (one
// materialization/projection implementation; no test-side mirror to drift).
export { SessionProvideChannel } from './sessions/provide.ts'
export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { ISessions } from './contract/sessions.ts'
export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
@@ -111,8 +118,10 @@ declare module 'cordis' {
}
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
workspaces: import('./workspaces/service.ts').WorkspacesService
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** The outward face only; the concrete service stays inside the runtime. */
workspaces: import('./contract/workspaces.ts').IWorkspaces
}
}

View File

@@ -212,6 +212,19 @@ export class SessionManager {
session.handleBlank(s.blank)
session.handleRunning(s.running)
}
// Seed each row's projection baseline into the per-session value
// store (cold titles surface without opening the session). Per-key
// apply, not seed(): the list block is a partial baseline — the
// cold cache serves only version-matching keys — so an absent key
// must not clear; higher-seq-wins still keeps a stale list block
// from overwriting a newer push frame or tail baseline.
for (const s of result.value.items) {
const block = s.projections
if (block === undefined) continue
const store = this.projectionStore(s.sessionId)
const values = block.values as Record<string, unknown>
for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq)
}
} else {
this.listState = 'error'
this.listError = result.error

View File

@@ -0,0 +1,190 @@
/**
* The session standard-props provide channel: provider roster, bundle
* materialization (fail-loud on undeclared/missing/duplicate members), the
* static no-session projection, and the atomic current-session projection
* observable. One implementation — SessionsService drives it from wire
* truth, the test runtime's sessions double drives it from fixtures — so
* the materialization rules and the projection semantics cannot drift
* between production and the test bench.
*/
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionBinding, SessionProvideDescriptor } from './service.ts'
/** The owner-side hooks: how the channel reaches the owner's live bundles and current selection. */
export interface SessionProvideChannelHost {
/**
* Re-materialize every already-materialized bundle against the new roster
* (call {@link SessionProvideChannel.materializeInfo} per live binding).
* Lazily-materialized sessions pick the new roster up on first resolve.
*/
rebuildBundles(): void
/** Resolve the current selection's bundle (the owner's maybe-provide lookup). */
resolveCurrent(): SessionMaybeProvideInfo
}
/**
* Provider roster + materialization + current projection. The channel owns
* every rule a provider contribution must satisfy; owners keep only their
* per-session bundle storage and the definition of "current".
*/
export class SessionProvideChannel {
private readonly providers: SessionProvideDescriptor[] = []
private maybeInfoCache: SessionMaybeProvideInfo
/** Latest published current bundle (identity comparison dedupes republish). */
private currentSnapshot: SessionMaybeProvideInfo
/** Projection subscribers (plain cell: bundles hold live session sources, so no store freeze may touch them). */
private readonly listeners = new Set<() => void>()
/**
* Atomic current-session provide projection: selection changes and
* provider-roster changes publish through this one source, so a roster
* change under a stable current id republishes the bundle instead of
* stranding mounted entries.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* @param host - owner-side bundle storage and current-selection resolution.
*/
constructor(private readonly host: SessionProvideChannelHost) {
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
hooks: ['session'],
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfoCache = this.materializeMaybeInfo()
this.currentSnapshot = this.maybeInfoCache
this.currentProvideInfo = {
getSnapshot: () => this.currentSnapshot,
subscribe: (fn) => {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
},
}
}
/** The static no-session projection under the current roster (declared names present, values undefined). */
get maybeInfo(): SessionMaybeProvideInfo {
return this.maybeInfoCache
}
/**
* Register a per-session standard-props provider (see
* SessionsService.provide for the product contract). Live bundles rebuild
* immediately; misdeclared providers fail loud here, at the registration
* edge, and the registration rolls back — the channel never stays on a
* roster it cannot materialize.
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider.
*/
provide(descriptor: SessionProvideDescriptor): () => void {
this.providers.push(descriptor)
try {
this.applyRosterChange()
} catch (error) {
this.providers.splice(this.providers.indexOf(descriptor), 1)
// Restore the previous (valid) roster's bundles; cannot rethrow — the
// pre-push roster materialized successfully before.
this.applyRosterChange()
throw error
}
return () => {
const at = this.providers.indexOf(descriptor)
if (at >= 0) this.providers.splice(at, 1)
this.applyRosterChange()
}
}
/**
* Re-derive the current selection's bundle and publish it when it changed.
* Bundles are identity-stable per (scope, roster) materialization, so an
* identity compare is exact; synchronous notify — call sites (the owner's
* list subscription, provide()) already sit behind their own batching or
* registration edges.
*/
publishCurrent(): void {
const next = this.host.resolveCurrent()
if (next === this.currentSnapshot) return
this.currentSnapshot = next
for (const fn of [...this.listeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
}
/**
* Materialize the standard-props bundle for one session (fails loud on
* undeclared, missing, and duplicate member names).
* @param binding - session assembly handle fed to every resolver.
* @returns the materialized bundle (identity-stable until the next materialization).
*/
materializeInfo(binding: SessionBinding): SessionProvideInfo {
const hooks: Record<string, HostObservable<unknown>> = {}
const props: Record<string, unknown> = {}
for (const descriptor of this.providers) {
const contribution = descriptor.resolve(binding)
const contributedHooks = contribution.hooks ?? {}
const contributedProps = contribution.props ?? {}
for (const name of Object.keys(contributedHooks)) {
if (!(descriptor.hooks ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared hook "${name}"`)
}
}
for (const name of Object.keys(contributedProps)) {
if (!(descriptor.props ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared prop "${name}"`)
}
}
for (const name of descriptor.hooks ?? []) {
const source = contributedHooks[name]
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = source
}
for (const name of descriptor.props ?? []) {
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = contributedProps[name]
}
}
return {
sessionId: binding.sessionId,
hooks,
props,
// The useProjection seat: key-addressed bare value faces off the
// session's projection store (open key space — never a static roster member).
projections: { faceOf: key => binding.session.projections.faceOf(key) },
}
}
/** Rebuild the static projection and the owner's live bundles, then republish the current one. */
private applyRosterChange(): void {
this.maybeInfoCache = this.materializeMaybeInfo()
this.host.rebuildBundles()
this.publishCurrent()
}
/** Build the static no-session kit and reject duplicate declared names. */
private materializeMaybeInfo(): SessionMaybeProvideInfo {
const hooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
for (const descriptor of this.providers) {
for (const name of descriptor.hooks ?? []) {
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = undefined
}
for (const name of descriptor.props ?? []) {
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = undefined
}
}
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
}
}

View File

@@ -22,9 +22,12 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
@@ -79,7 +82,8 @@ export class SessionCreateError extends Error {
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
readonly sessionId: SessionId
readonly session: Session
/** The outward session face only — feature code never sees the concrete class. */
readonly session: SessionFace
readonly ctx: Context
}
@@ -119,6 +123,8 @@ interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
/** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
session: Session
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
provideInfo: SessionProvideInfo
}
@@ -146,7 +152,7 @@ export interface SessionProvideDescriptor {
}
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
export class SessionsService implements ISessions {
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */
@@ -170,14 +176,8 @@ export class SessionsService {
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Registered per-session standard-props providers, in registration order. */
private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
private readonly currentProvideInfoListeners = new Set<() => void>()
/** The provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
private readonly provideChannel: SessionProvideChannel
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
@@ -212,23 +212,17 @@ export class SessionsService {
// The current-provide projection follows the same current writes.
this.list.subscribe(() => {
this.followCurrent()
this.updateCurrentProvideInfo()
this.provideChannel.publishCurrent()
})
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
hooks: ['session'],
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfo = this.materializeMaybeProvideInfo()
this.currentProvideInfoSnapshot = this.maybeInfo
this.currentProvideInfo = {
getSnapshot: () => this.currentProvideInfoSnapshot,
subscribe: (fn) => {
this.currentProvideInfoListeners.add(fn)
return () => { this.currentProvideInfoListeners.delete(fn) }
this.provideChannel = new SessionProvideChannel({
rebuildBundles: () => {
for (const record of this.scopes.values()) {
record.provideInfo = this.provideChannel.materializeInfo(record.binding)
}
},
}
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
})
this.currentProvideInfo = this.provideChannel.currentProvideInfo
rootCtx.reflect.provide('sessions', this, undefined)
}
@@ -243,105 +237,10 @@ export class SessionsService {
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
*/
provide(descriptor: SessionProvideDescriptor): () => void {
this.providers.push(descriptor)
// Scopes may already exist (boot order: the list lands and resolves
// scopes before later plugins register) — their bundles must include
// every provider by first render, so re-materialize on roster change.
this.rematerializeProvideBundles()
return () => {
const at = this.providers.indexOf(descriptor)
if (at >= 0) this.providers.splice(at, 1)
this.rematerializeProvideBundles()
}
}
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
private rematerializeProvideBundles(): void {
this.maybeInfo = this.materializeMaybeProvideInfo()
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
this.updateCurrentProvideInfo()
}
/**
* Re-derive the current selection's provide bundle and publish it when it
* changed. Bundles are identity-stable per (scope, roster)
* materialization, so an identity compare is exact; synchronous notify —
* both call sites (list.subscribe, provide()) already sit behind their own
* batching or registration edges.
*/
private updateCurrentProvideInfo(): void {
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
if (next === this.currentProvideInfoSnapshot) return
this.currentProvideInfoSnapshot = next
for (const fn of [...this.currentProvideInfoListeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
}
/** Build the static no-session kit and reject duplicate declared names. */
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
const hooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
for (const descriptor of this.providers) {
for (const name of descriptor.hooks ?? []) {
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = undefined
}
for (const name of descriptor.props ?? []) {
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = undefined
}
}
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
}
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
const hooks: Record<string, HostObservable<unknown>> = {}
const props: Record<string, unknown> = {}
for (const descriptor of this.providers) {
const contribution = descriptor.resolve(binding)
const contributedHooks = contribution.hooks ?? {}
const contributedProps = contribution.props ?? {}
for (const name of Object.keys(contributedHooks)) {
if (!(descriptor.hooks ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared hook "${name}"`)
}
}
for (const name of Object.keys(contributedProps)) {
if (!(descriptor.props ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared prop "${name}"`)
}
}
for (const name of descriptor.hooks ?? []) {
const source = contributedHooks[name]
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = source
}
for (const name of descriptor.props ?? []) {
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = contributedProps[name]
}
}
return {
sessionId: binding.sessionId,
hooks,
props,
// The useProjection seat: key-addressed bare value faces off the
// session's projection store (open key space — never a static roster member).
projections: { faceOf: key => binding.session.projections.faceOf(key) },
}
// scopes before later plugins register) — the channel rebuilds their
// bundles through the host hooks so every provider lands by first render.
return this.provideChannel.provide(descriptor)
}
/**
@@ -439,9 +338,9 @@ export class SessionsService {
* `agent.session`). Same service-method seam as
* {@link SessionsService.scopeOf}.
* @param ctx - an Agent-scoped context.
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
* @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
*/
sessionOf(ctx: Context): Session | undefined {
sessionOf(ctx: Context): SessionFace | undefined {
const id = scopeTagOf(ctx)
if (id === undefined) return undefined
return this.scopes.get(id)?.binding.session
@@ -473,7 +372,7 @@ export class SessionsService {
* return the static no-session projection rather than removing hook props.
*/
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.provideChannel.maybeInfo
}
/**
@@ -497,7 +396,7 @@ export class SessionsService {
* validates and the projection masks absent selections), so resolve
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.binding.session.open()
void record.session.open()
}
}
@@ -540,8 +439,9 @@ export class SessionsService {
fiber,
ctx,
binding,
session,
// Sources are bare observables; React binds selector hooks at its own seam.
provideInfo: this.materializeProvideInfo(binding),
provideInfo: this.provideChannel.materializeInfo(binding),
}
this.scopes.set(id, record)
return record
@@ -608,7 +508,7 @@ export class SessionsService {
void record.fiber.dispose()
// Release the Session's dispatch point with the scope it belongs to (a
// surviving instance — the live Intent — rebinds when resolve re-mints).
record.binding.session.unbindScope()
record.session.unbindScope()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)

View File

@@ -10,7 +10,7 @@ import type {
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type { SessionFace } from '../contract/session.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
PromptError, QueuedMessage, RunningToolCall,
@@ -67,9 +67,11 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer.
* snapshot. React bindings remain outside this data layer. Features see only
* the {@link SessionFace} slice (ISession verbs + the snapshot source); the
* remaining public members are manager/runtime entry points.
*/
export class Session implements ObservableSnapshot<ConversationSnapshot> {
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
private events: SessionEvent[] = []
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).

View File

@@ -7,7 +7,8 @@ import type {
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsService } from '../sessions/service.ts'
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
import type { IWorkspaces } from '../contract/workspaces.ts'
import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
/** Workspace list plus the two-baseline readiness and default-target projection. */
@@ -39,7 +40,7 @@ export class DirectoryBrowseError extends Error {
}
/** Real Workspace object layer and Host actions. */
export class WorkspacesService {
export class WorkspacesService implements IWorkspaces {
/** UI-facing immutable projection; the manager remains wire truth. */
readonly list: SnapshotStore<WorkspaceListState>
/** Workspace baseline and frame owner. */
@@ -52,9 +53,9 @@ export class WorkspacesService {
/**
* @param ctx - client root context.
* @param api - shared wire client.
* @param sessions - lower-level Session service used for recency and blank-session reuse.
* @param sessions - cross-domain sessions face used for recency and blank-session reuse.
*/
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) {
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
this.manager = new WorkspaceManager(api)
this.list = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'pending', error: null,
@@ -303,7 +304,7 @@ export class WorkspacesService {
/** Stable tie-breaking follows Host Workspace order. */
function recentWorkspace(
workspaces: readonly WorkspaceView[],
sessions: ReturnType<SessionsService['list']['getSnapshot']>['byId'],
sessions: SessionsPortList['byId'],
): WorkspaceId | undefined {
let selected: WorkspaceId | undefined
let selectedTime = Number.NEGATIVE_INFINITY

View File

@@ -160,6 +160,28 @@ describe('list lifecycle', () => {
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
})
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api)
// A push frame landed before the list (S2's title is newer than the block's cut).
manager.handleMuxEnvelope({
rpcId: 'push-newer' as never,
payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never,
})
api.onList = () => Promise.resolve(ok({
items: [
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
] as never[],
}))
await manager.refreshList()
const items = manager.getListSnapshot().items
// Cold row: title surfaces straight from the list block — no open, no history.
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
})
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/test-runtime/README.md
README.md: 883d71224139dc409229fdfb35362d040e810cc7
README.zh.md: a3daf112940b03b585d44bc5fd1317e43ebd35fe

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-client-test-runtime
English | [中文](README.zh.md)
jsdom slot test runtime for client feature specs: a real Cordis `Context`, the production `SlotsService` and web-react renderer, assembled around typed session/workspace doubles. Feature suites exercise declaration, registration, scope, store, inject, rendering, updates, and disposal without hand-building the machinery per suite — and without a second implementation of any production logic.
The doubles implement the same outward faces features receive through ctx (`TestSessions implements ISessions`, `TestWorkspaces implements IWorkspaces`; each fixture session is a `FixtureSession implements SessionFace`), so a production face change breaks the bench at compile time instead of silently drifting. Provide-bundle materialization runs the production `SessionProvideChannel` — the one implementation shared with `SessionsService`. Fixtures feed plain data: list rows, conversation snapshots (immer-patched via `updateSnapshot`), projection values, and `ISession`-typed behavior stubs that fail loud when a spec calls an unstubbed verb. The typed `provide()` constrains fakes for declared service names to `Partial` of that service's outward face.
Local DOM snapshots: `declare(children)` registers an auto frame whose per-key `<div data-slot>` wrappers are snapshot roots; `renderSlot(key, owner)` returns the slot-local view (container, scoped Testing Library queries, in-place `update(owner)`); a registered snapshot serializer folds CSS-module class hashes (`_frame_a1b2c3``frame`) to keep `.snap` files structural and collapses `<svg>` internals to a `data-content` fingerprint. Suites needing a custom page frame use `root.declare(children, Frame)` instead; `mount(plugin)` runs a real fiber with fail-loud service prechecks, and `dispose()` tears down views, feature fibers, minted scopes, and persisted store state on one axis.
Not part of the product plugin graph (no `dshClient`); feature packages depend on it in `devDependencies` only.
## Model Experience
None, as this package is browser-side test infrastructure; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Consumed through repository source aliases only.** Specs resolve the package through tsconfig `paths` to `src`; the built `lib/` artifact re-exports `@deepseek-ai/dsh-client-runtime/client`, whose bundle is a browser loader script with no Node ESM exports, so `lib/index.js` is not importable under plain Node. Acceptable while every consumer is an in-repo Vitest suite; a Node-compatible runtime entry is deferred until an out-of-repo consumer exists.
- **Conversation snapshots are fixture data, not replayed history.** `updateSnapshot` writes the snapshot store directly; the wire-to-snapshot computation stays covered by the runtime package's own tests and the replay e2e. A fixture can therefore express states the production fold would never produce.

View File

@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-client-test-runtime
[English](README.md) | 中文
面向 client feature 测试的 jsdom slot 测试运行时:真实 Cordis `Context`、生产 `SlotsService` 与 web-react 渲染器,围绕带类型的 session/workspace 测试替身组装。feature 套件无需逐套件手搭机器即可测遍声明、注册、scope、store、inject、渲染、更新与销毁——且不存在任何生产逻辑的第二份实现。
替身实现的正是 feature 经 ctx 拿到的对外面(`TestSessions implements ISessions``TestWorkspaces implements IWorkspaces`;每个 fixture session 是 `FixtureSession implements SessionFace`生产面一旦改形测试台在编译期即断而非静默漂移。provide bundle 材料化直接运行生产 `SessionProvideChannel`——与 `SessionsService` 共用同一份实现。fixture 灌入的是普通数据:列表行、会话快照(经 `updateSnapshot` 以 immer 补丁改写、projection 值,以及按 `ISession` 取型的行为桩——spec 调用未打桩的动词时报错自明。带类型的 `provide()` 将已声明服务名的 fake 约束为该服务对外面的 `Partial` 子集。
局部 DOM 快照:`declare(children)` 注册自动 frame逐 key 的 `<div data-slot>` 包裹层即快照根;`renderSlot(key, owner)` 返回该 slot 的局部视图container、限定范围的 Testing Library 查询、原位 `update(owner)`);注册的快照序列化器把 CSS-module 哈希类名折回语义名(`_frame_a1b2c3``frame`)保持 `.snap` 只含结构,并把 `<svg>` 内部折叠为 `data-content` 指纹。需要自定义页面 frame 的套件改用 `root.declare(children, Frame)``mount(plugin)` 在真实 fiber 上运行并对缺失服务先行报错;`dispose()` 沿单一轴拆除视图、feature fiber、已铸 scope 与持久化 store 状态。
不属于产品插件图(无 `dshClient`feature 包仅以 `devDependencies` 依赖之。
## Model Experience
无;本包是浏览器侧测试基础设施,无一物到达模型请求。
#### KV Cache effect
无;本包既不组装也不发送 provider 请求。
## Known Limitations and Deferred Work
- **仅可经仓内源码别名消费。** spec 通过 tsconfig `paths` 解析到 `src`;构建产物 `lib/` 再导出 `@deepseek-ai/dsh-client-runtime/client`,而该 bundle 是无 Node ESM 导出的浏览器 loader 脚本,故 `lib/index.js` 在纯 Node 下不可导入。当前所有消费方都是仓内 Vitest 套件可接受Node 兼容的运行时入口待出现仓外消费方再补。
- **会话快照是 fixture 数据,不是重放历史。** `updateSnapshot` 直写快照 storewire 到快照的运算仍由 runtime 包自身测试与 replay e2e 把守。因此 fixture 可以表达生产折叠永不产出的状态。

View File

@@ -0,0 +1,54 @@
{
"name": "@deepseek-ai/dsh-client-test-runtime",
"description": "jsdom slot test runtime: real Cordis Context + SlotsService + web-react renderer with test-owned session/workspace doubles for feature specs",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"license": "BSD-3-Clause",
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"vitest": "^4.1.8"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,82 @@
/** Session/workspace fixture shapes and snapshot defaults for the test runtime. */
import type {
ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
/**
* Fixture overrides for the session behavior face: any subset of the
* production ISession verbs (typed against it, so a face change surfaces
* here at compile time), plus extra members feature-specific casts consume.
* The open Record tail means a misnamed EXTRA member is not caught by the
* compiler (it grafts as dead weight); the ISession verbs stay safe — a
* misnamed verb leaves the fail-loud stub in place, which names itself at
* the first call.
*/
export type SessionBehaviorOverrides = Partial<ISession> & Record<string, unknown>
/**
* act-wrapped mutation runner shared by every runtime object: public mutators
* funnel through it so tests never handle SlotCore microtask batching or
* React act themselves.
*/
export type Stabilizer = (fn: () => void | Promise<void>) => Promise<void>
/**
* Session fixture accepted by {@link TestSessions.add}: identity plus optional
* snapshot/list-row overrides and the session behavior face the feature under
* test actually calls (kept open — the runtime never fakes methods a test did
* not supply, so an unstubbed call fails loud at the call site).
*/
export interface SessionFixture {
id: string
/** Overrides merged over {@link conversationSnapshot} (sessionId comes from `id`). */
snapshot?: Partial<Omit<ConversationSnapshot, 'sessionId'>>
/** List-row overrides merged over the defaults derived from `id`. */
summary?: Partial<Omit<SessionSummary, 'id'>>
/** Session behavior face: exactly the methods the feature under test calls (ISession subset + extras). */
session?: SessionBehaviorOverrides
}
/**
* A complete quiescent conversation snapshot (open window, no traffic).
* @param sessionId - owning session id.
* @returns the snapshot; spread fixture overrides on top.
*/
export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot {
return {
sessionId,
nodes: [],
foldDegraded: false,
partial: null,
runningCalls: [],
codeDispatches: new Map(),
pending: [],
queue: [],
running: false,
composerPhase: 'active',
removed: false,
openState: 'open',
openError: null,
hasMore: false,
loadingOlder: false,
promptError: null,
blank: false,
lastAgentError: null,
}
}
/**
* A ready workspace list with no workspaces (the shape WorkspacesService
* projects after both baselines land).
* @returns the initial state of the test workspaces store.
*/
export function workspaceListState(): WorkspaceListState {
return {
items: [],
state: 'idle',
phase: 'ready',
error: null,
baselinesReady: true,
recentWorkspaceId: undefined,
}
}

View File

@@ -0,0 +1,372 @@
/**
* jsdom slot test runtime: a real small runtime — Cordis `Context`, the
* runtime `SlotsService`, and the web-react renderer — assembled around
* test-owned session/workspace doubles, so feature specs exercise
* declaration, registration, scope, store, inject, rendering, updates, and
* disposal without hand-building the machinery per suite.
*
* Not part of the product plugin graph (no `dshClient`); feature packages
* depend on it in devDependencies only. It copies no SlotCore/renderer/store
* machinery — everything mounts the production implementations.
* @module @deepseek-ai/dsh-client-test-runtime
*/
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
* `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots):
* this compilation unit sees only the runtime's 'root' row, but consumer
* programs merge their own keys in; the rule fires on the narrow-map view. */
import { Context, Inject } from 'cordis'
import type { Fiber, Plugin } from 'cordis'
import { createElement, Fragment, useSyncExternalStore } from 'react'
import type { ReactNode } from 'react'
import { act, render, within } from '@testing-library/react'
import type { RenderResult } from '@testing-library/react'
import type { queries } from '@testing-library/dom'
import type { BoundFunctions } from '@testing-library/dom'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type {
ChildrenDecl, ComposedProps, OwnerOf, SlotComponent, SlotMap, SlotRendererHost, StoreInstanceLike,
} from '@deepseek-ai/dsh-client-ui-slots'
import { registerDomSnapshotSerializer } from './snapshot.ts'
import { TestSessions } from './sessions.ts'
import { TestWorkspaces } from './workspaces.ts'
import type { Stabilizer } from './fixtures.ts'
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
export { FixtureSession, TestSessions } from './sessions.ts'
export { TestWorkspaces } from './workspaces.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
/** Erased register face for the internal root call (the public declare seam holds the typing). */
type ErasedRegister = (options: object, component: unknown) => () => void
/**
* One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}:
* the `data-slot` wrapper is the snapshot root (`expect(view.container)
* .toMatchSnapshot()` captures exactly this slot's output), Testing Library
* queries are bound inside it, and `update` re-renders with new owner props.
*/
export interface SlotView<K extends keyof SlotMap & string> {
/** The `<div data-slot="<key>">` wrapper around the slot's rendered output. */
readonly container: HTMLElement
/** Testing Library queries scoped to {@link SlotView.container}. */
readonly view: BoundFunctions<typeof queries>
/**
* 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.
*/
update(owner: OwnerOf<K>): void
}
/**
* Mounted feature plugin handle: the live fiber plus an act-wrapped,
* idempotent dispose (unload cascade: entries, declared child slots, store
* instances, and provided services all fall together).
*/
export interface FeatureHandle {
/** The plugin's live Cordis fiber (state assertions, escape hatch). */
readonly fiber: Fiber
/**
* Dispose the plugin fiber inside React act; repeated calls no-op.
* @returns completion of the unload cascade.
*/
dispose(): Promise<void>
}
/**
* Owner-props cell behind the auto frame: one external store the frame
* subscribes to, so {@link SlotTestRuntime.renderSlot} and
* {@link SlotView.update} drive React through the standard uSES seam.
*/
class OwnerPropsCell {
private readonly owners = new Map<string, object>()
private readonly listeners = new Set<() => void>()
private version = 0
/** Snapshot version for uSES pairing (bumped on every set). */
readonly getVersion = (): number => this.version
/**
* Subscribe to owner-props changes.
* @param fn - change callback.
* @returns unsubscribe.
*/
readonly subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
}
/**
* Install or replace one key's owner props and notify (synchronous; the
* caller wraps in act).
* @param key - slot key.
* @param owner - owner props share.
*/
set(key: string, owner: object): void {
this.owners.set(key, owner)
this.version += 1
for (const fn of [...this.listeners]) fn()
}
/** Keys with supplied owner props, in first-supply order. */
entries(): readonly (readonly [string, object])[] {
return [...this.owners.entries()]
}
}
/**
* The test-owned 'root' occupant: declares the child slots a suite needs
* through the REAL `slots.register`, with a caller-supplied minimal frame —
* the runtime never guesses a feature's page structure.
*/
export class TestRoot {
private disposeEntry: (() => void) | undefined
/**
* @param slots - the runtime SlotsService.
* @param stabilize - the owning runtime's act wrapper.
*/
constructor(private readonly slots: SlotsService, private readonly stabilize: Stabilizer) {}
/**
* Register the root frame, declaring (and thereby claiming) the child
* slots. One declaration per runtime — a second call fails loud in the
* core ('root' is a single slot).
* @param children - child-slot declaration table (declaration + render authorization + runtime spec).
* @param frame - minimal frame component; its props derive from the declared keys (composed-props contract).
* @returns completion of the act-wrapped registration.
*/
async declare<const D extends ChildrenDecl>(
children: D,
frame: SlotComponent<ComposedProps<'root', keyof NoInfer<D> & keyof SlotMap & string, undefined, object>>,
): Promise<void> {
await this.stabilize(() => {
// Erased hop (same pattern as SlotsService's own implementation arm);
// the declare signature above is the typed seam.
this.disposeEntry = (this.slots.register as unknown as ErasedRegister)({ name: 'root', children }, frame)
})
}
/** Remove the root registration and collapse its declarations (runtime dispose path). */
release(): void {
this.disposeEntry?.()
this.disposeEntry = undefined
}
}
/**
* The assembled test runtime. Obtain via {@link SlotTestRuntime.create};
* dispose with {@link SlotTestRuntime.dispose} (afterEach). Public mutators
* are act-wrapped throughout — tests never handle SlotCore microtask
* batching or React act themselves.
*/
export class SlotTestRuntime {
/** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */
readonly ctx: Context
/** The production SlotsService mounted on {@link SlotTestRuntime.ctx}. */
readonly slots: SlotsService
/** The test-owned 'root' occupant. */
readonly root: TestRoot
/** Sessions double (list/current observable, cells, scopes, behavior faces). */
readonly sessions: TestSessions
/** Workspaces double (list observable, recorded intent actions). */
readonly workspaces: TestWorkspaces
private readonly stabilizer: Stabilizer = async (fn) => {
await act(async () => { await fn() })
}
private host: SlotRendererHost | undefined
private readonly views: RenderResult[] = []
private readonly handles: FeatureHandle[] = []
private disposed = false
/** Auto-frame state ({@link SlotTestRuntime.declare} / {@link SlotTestRuntime.renderSlot}). */
private readonly ownerCell = new OwnerPropsCell()
private readonly autoDeclared = new Set<string>()
private autoRootView: RenderResult | undefined
private constructor(ctx: Context, slots: SlotsService) {
this.ctx = ctx
this.slots = slots
this.root = new TestRoot(slots, this.stabilizer)
this.sessions = new TestSessions(this.stabilizer, ctx)
this.workspaces = new TestWorkspaces(this.stabilizer)
ctx.provide('sessions', this.sessions)
ctx.provide('workspaces', this.workspaces)
// Capturing install: the production renderer does the rendering; the
// wrapper only takes the host face for storeOf (no machinery copied).
const renderer = createSlotRenderer()
slots.install({
renderRoot: (host, ownerProps) => {
this.host = host
return renderer.renderRoot(host, ownerProps)
},
})
}
/**
* Assemble a runtime: real Context, mounted SlotsService, installed
* renderer, and the session/workspace doubles provided as services.
* @returns the ready runtime.
*/
static async create(): Promise<SlotTestRuntime> {
registerDomSnapshotSerializer()
const ctx = new Context()
const fiber = ctx.plugin(SlotsService)
await fiber.await()
return new SlotTestRuntime(ctx, ctx.get('slots') as SlotsService)
}
/**
* Provide an extra service the feature under test injects (e.g. a layout
* fake). Sugar over `ctx.provide`, typed against the Context declaration
* merge: for a declared service name the fake must be a subset of that
* service's outward face (Partial — supply only what the feature calls),
* so a production face change breaks the fake at compile time. Undeclared
* names stay unchecked (ad-hoc test services).
* @param name - service name.
* @param value - service implementation (test double).
*/
provide<K extends string>(name: K, value: K extends keyof Context ? Partial<Context[K]> : unknown): void {
this.ctx.provide(name, value)
}
/**
* Mount a feature plugin on a real fiber. Required services are prechecked
* so a missing provider fails loud instead of suspending the fiber forever
* (deliberate load-order suspension tests use `ctx.plugin` directly).
* @param plugin - plugin value (function, class, or `{ inject, apply }` object).
* @returns handle owning the fiber's explicit disposal.
*/
async mount(plugin: Plugin): Promise<FeatureHandle> {
const required = Object.keys(Inject.resolve((plugin as { inject?: Inject }).inject))
const missing = required.filter(name => this.ctx.get(name) === undefined)
if (missing.length > 0) {
throw new Error(`mount would suspend: missing service(s) ${missing.join(', ')} — provide() them first`)
}
const fiber = this.ctx.plugin(plugin)
await this.stabilizer(async () => {
await fiber.await()
})
let disposed = false
const handle: FeatureHandle = {
fiber,
dispose: async () => {
if (disposed) return
disposed = true
await this.stabilizer(() => fiber.dispose())
},
}
this.handles.push(handle)
return handle
}
/**
* Render the root slot tree through the ctx-level entry (the shell's own
* seam): `ctx.slots.renderSlot('root', {})` under Testing Library.
* @returns the Testing Library view.
*/
renderRoot(): RenderResult {
const view = render(createElement(Fragment, null, this.slots.renderSlot('root', {})))
this.views.push(view)
return view
}
/**
* Declare child slots under an auto-generated root frame — the single-slot
* mounting path for local DOM snapshots. Each key later supplied through
* {@link SlotTestRuntime.renderSlot} renders inside its own
* `<div data-slot="<key>">` wrapper (the snapshot root). Mutually exclusive
* with {@link TestRoot.declare} ('root' is a single slot); one call per
* runtime.
* @param children - child-slot declaration table (same contract as TestRoot.declare).
* @returns completion of the act-wrapped registration.
*/
async declare(children: ChildrenDecl): Promise<void> {
for (const key of Object.keys(children)) this.autoDeclared.add(key)
const cell = this.ownerCell
const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => {
useSyncExternalStore(cell.subscribe, cell.getVersion)
return createElement(Fragment, null, cell.entries().map(([key, owner]) =>
createElement('div', { 'data-slot': key, key }, props.renderSlot(key, owner))))
}
await this.root.declare(children as never, AutoFrame as never)
}
/**
* Render one declared slot with its owner props and return the local view.
* The whole root tree mounts through the production assembly path
* (renderer, scope providers, store axis); only this key's output lands in
* the returned container. Call again with another key to view a sibling
* slot of the same tree.
* @param key - a key declared through {@link SlotTestRuntime.declare}.
* @param owner - owner props share for the render site.
* @returns the slot-local view (snapshot container, scoped queries, owner updates).
*/
renderSlot<K extends keyof SlotMap & string>(key: K, owner: OwnerOf<K>): SlotView<K> {
if (!this.autoDeclared.has(key)) {
throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`)
}
const install = (next: object): void => {
// Synchronous cell write inside act: the frame re-renders through uSES.
act(() => {
this.ownerCell.set(key, next)
})
}
install(owner)
this.autoRootView ??= this.renderRoot()
const container = this.autoRootView.container.querySelector(`[data-slot="${key}"]`)
if (!(container instanceof HTMLElement)) {
throw new Error(`renderSlot('${key}'): the auto frame rendered no wrapper — was the runtime already disposed?`)
}
return { container, view: within(container), update: install }
}
/**
* Resolve the store instance the renderer would hand a slot's component
* (identity assertions, action-driven writes). Requires a prior
* {@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.
* @returns the live store instance.
*/
storeOf(key: keyof SlotMap & string, scopeKey?: string): 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 instance = this.host.storeOf(entry, scopeKey)
if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`)
return instance
}
/**
* Flush pending ledger/store notifications inside act — for mutations made
* outside the runtime's own methods (e.g. a direct `slots.register`).
* @returns completion of the act pass.
*/
async flush(): Promise<void> {
await this.stabilizer(() => {})
}
/**
* Tear down: unmount React trees first, then dispose feature fibers, the
* root registration, minted session scopes, and persisted test state.
* Idempotent.
* @returns completion of the teardown.
*/
async dispose(): Promise<void> {
if (this.disposed) return
this.disposed = true
this.autoRootView = undefined
for (const view of this.views.splice(0)) view.unmount()
for (const handle of this.handles.splice(0)) await handle.dispose()
this.root.release()
await this.sessions.disposeScopes()
localStorage.clear()
}
}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-test-runtime`.
* @module @deepseek-ai/dsh-client-test-runtime/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime'
/** Cordis companion plugin name. */
export const name = 'client-test-runtime-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this test-support package owns no production event
* stream or mutable data — it assembles the runtime SlotsService and renderer
* (whose packages own their invariants) around test doubles; its own behavior
* is exercised by its package tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,390 @@
/** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */
import type { Context } from 'cordis'
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import { conversationSnapshot } from './fixtures.ts'
import type { SessionFixture, Stabilizer } from './fixtures.ts'
/**
* The fixture-backed session face: conversation reads delegate to the
* fixture's snapshot store; ISession verbs are fail-loud stubs unless the
* fixture supplies them (the runtime never fakes behavior a test did not
* declare — an unstubbed call names itself instead of half-working). Extra
* fixture methods are grafted verbatim for feature-side casts.
*/
export class FixtureSession implements SessionFace {
/**
* The useProjection seat: identity-stable per-key faces over the fixture's
* projection values (set via {@link TestSessions.setProjection}).
*/
readonly projections: ProjectionsFace & { set(key: string, value: unknown): void }
/**
* @param sessionId - host identity (branded view of the fixture id).
* @param store - conversation snapshot store (updateSnapshot writes it).
* @param overrides - fixture-declared behavior face, grafted over the stubs.
*/
constructor(
readonly sessionId: SessionId,
private readonly store: SnapshotStore<ConversationSnapshot>,
overrides: Record<string, unknown>,
) {
const values = new Map<string, unknown>()
const listeners = new Map<string, Set<() => void>>()
const faces = new Map<string, ObservableSnapshot<unknown>>()
this.projections = {
faceOf: (key: string) => {
let face = faces.get(key)
if (face === undefined) {
face = {
getSnapshot: () => values.get(key),
subscribe: (fn: () => void) => {
const set = listeners.get(key) ?? new Set()
set.add(fn)
listeners.set(key, set)
return () => { set.delete(fn) }
},
}
faces.set(key, face)
}
return face
},
set: (key: string, value: unknown) => {
values.set(key, value)
for (const fn of [...(listeners.get(key) ?? [])]) fn()
},
}
Object.assign(this, overrides)
}
/** @returns the fixture conversation snapshot (useSession read side). */
getSnapshot(): ConversationSnapshot {
return this.store.getSnapshot()
}
/**
* Subscribe to fixture snapshot changes.
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void {
return this.store.subscribe(fn)
}
/**
* Fail-loud stub; supply `prompt` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
prompt(): never {
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
cancel(): never {
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
loadOlder(): never {
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`)
}
}
/** One live test session: fixture-derived stores plus its minted scope state. */
interface SessionRecord {
summary: SessionSummary
snapshot: SnapshotStore<ConversationSnapshot>
session: FixtureSession
scope: Context | undefined
scopeFiber: { dispose(): Promise<void> } | undefined
/** Materialized standard-props bundle (identity-stable per session; invalidated on roster change). */
provideInfo: SessionProvideInfo | undefined
}
/** Test binding shape handed to provider resolvers and feature injects (a SessionBinding whose session is the fixture face). */
export interface TestSessionBinding {
readonly sessionId: SessionId
readonly session: FixtureSession
readonly ctx: Context
}
/**
* Sessions test double behind the renderer host and feature injects: owns the
* list/current observable, the standard-props provide channel (the runtime's
* `useSession` contribution included), scope minting through the production
* `createScope`, and the session behavior face supplied per fixture.
*
* 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/updateSnapshot/setCurrent/remove/behavior/calls and the
* legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
*/
export class TestSessions implements ISessions {
/** The useSessions standard feed (list rows + current selection). */
readonly list: SnapshotStore<SessionListState>
/**
* Atomic current-session provide projection (production SessionsService
* mirror): selection changes and provider-roster changes publish through
* this one source — the member the SlotsService host face hands the
* renderer's SessionProvider.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
private readonly records = new Map<SessionId, SessionRecord>()
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
private readonly channel: SessionProvideChannel
/** Calls observed on the service-level face (open/clear), newest last. */
readonly calls: { method: 'open' | 'clear'; args: unknown[] }[] = []
/**
* @param stabilize - the owning runtime's act wrapper.
* @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
*/
constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) {
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
})
this.channel = new SessionProvideChannel({
rebuildBundles: () => {
for (const record of this.records.values()) {
if (record.provideInfo !== undefined) {
record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record))
}
}
},
resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
})
this.currentProvideInfo = this.channel.currentProvideInfo
// The projection follows every current write, as in production.
this.list.subscribe(() => { this.channel.publishCurrent() })
}
/**
* Add a session from a fixture and (by default) make it current.
* @param fixture - identity + snapshot/summary overrides + behavior face.
* @param opts - pass `current: false` to add without selecting.
* @returns the stable session id (branded view of `fixture.id`).
*/
async add(fixture: SessionFixture, opts?: { current?: boolean }): Promise<SessionId> {
const id = fixture.id as SessionId
if (this.records.has(id)) throw new Error(`test session "${id}" already added`)
const summary: SessionSummary = {
id,
displayTitle: fixture.id,
running: false,
blank: false,
updatedAt: this.records.size + 1,
...fixture.summary,
}
const snapshot = createSnapshotStore<ConversationSnapshot>({
...conversationSnapshot(id),
...fixture.snapshot,
})
this.records.set(id, {
summary,
snapshot,
session: new FixtureSession(id, snapshot, fixture.session ?? {}),
scope: undefined,
scopeFiber: undefined,
provideInfo: undefined,
})
await this.stabilize(() => {
this.list.update((draft) => {
draft.ids.push(id)
draft.byId[id] = summary
if (opts?.current !== false) draft.current = id
})
})
return id
}
/**
* Update a session's conversation snapshot through an immer draft (the
* live-stream stand-in: components subscribed via useSession re-render).
* @param id - session id.
* @param mutate - draft mutator.
*/
async updateSnapshot(id: string, mutate: (draft: ConversationSnapshot) => void): Promise<void> {
const record = this.require(id)
await this.stabilize(() => { record.snapshot.update(mutate) })
}
/**
* Switch the current selection (undefined = the no-session empty state).
* @param id - session id to select, or undefined to clear.
*/
async setCurrent(id: string | undefined): Promise<void> {
if (id !== undefined) this.require(id)
await this.stabilize(() => {
this.list.update((draft) => { draft.current = id as SessionId | undefined })
})
}
/**
* Remove a session: list row, scope fiber, and per-session store instances
* (with persisted state) die together — the same single lifecycle axis the
* production SessionsService drives on session death, minus staging.
* @param id - session id.
*/
async remove(id: string): Promise<void> {
const record = this.require(id)
this.records.delete(id as SessionId)
await this.stabilize(async () => {
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.rootCtx.get('slots')?.pruneStoreScope(id)
})
}
/**
* Register a per-session standard-props provider (production `provide`
* contract: hooks become `use<Name>` selector hooks on the render side,
* props spread verbatim; duplicate names fail loud at materialization).
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider.
*/
provide(descriptor: SessionProvideDescriptor): () => void {
return this.channel.provide(descriptor)
}
/**
* Resolve the definite per-session standard-props bundle (host face member).
* @param id - session id.
* @returns the identity-stable bundle, or undefined for unknown sessions.
*/
provideInfo(id: string): SessionProvideInfo | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id as SessionId, record))
return record.provideInfo
}
/**
* Resolve the current-session-optional standard kit (host face member):
* unknown or absent ids return the static no-session projection.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.channel.maybeInfo
}
/**
* Resolve (mint on first touch) the session-scoped Cordis context through
* the production `createScope`, so real `scopeOf`/scope-addressed services
* resolve it.
* @param id - session id.
* @returns the scoped context, or undefined for unknown sessions.
*/
scope(id: string): Context | 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
}
/**
* Session assembly binding (inject factories and provide resolvers receive it).
* @param id - session id.
* @returns sessionId + behavior face + scoped ctx, or undefined when unknown.
*/
binding(id: string): TestSessionBinding | undefined {
const record = this.records.get(id as SessionId)
if (record === undefined) return undefined
return this.bindingOf(id as SessionId, record)
}
/**
* Read the session scope tag off a context (service-method seam mirror).
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeOf(ctx)
}
/**
* Resolve the scoped session face off a context (production `sessionOf`
* mirror).
* @param ctx - any client context.
* @returns the fixture session face, or undefined off-scope.
*/
sessionOf(ctx: Context): SessionFace | undefined {
const id = scopeOf(ctx)
if (id === undefined) return undefined
return this.records.get(id)?.session
}
/**
* 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 })
}
/** 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 })
}
/**
* The session face of a fixture (typed view for assertions; fixture
* behavior methods are grafted onto it).
* @param id - session id.
* @returns the FixtureSession the binding and provide channel carry.
*/
behavior(id: string): FixtureSession {
return this.require(id).session
}
/** Dispose minted scope fibers (runtime dispose path). */
async disposeScopes(): Promise<void> {
for (const record of this.records.values()) {
if (record.scopeFiber !== undefined) {
await record.scopeFiber.dispose()
record.scope = undefined
record.scopeFiber = undefined
}
}
}
private bindingOf(id: SessionId, record: SessionRecord): TestSessionBinding {
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, ctx }
}
private require(id: string): SessionRecord {
const record = this.records.get(id as SessionId)
if (record === undefined) throw new Error(`test session "${id}" is not added`)
return record
}
}

View File

@@ -0,0 +1,89 @@
/**
* DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
* files structural. Two normalizations, both on a clone (the live DOM is
* untouched, so class/tag queries keep working):
*
* - CSS-module scoped class names (`_frame_334d2d`, this repo's
* `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so
* CSS edits do not churn snapshots.
* - `<svg>` internals collapse to a `data-content` fingerprint on the svg
* element: path geometry is print noise, but the fingerprint still flips
* when an icon's artwork actually changes.
*/
import { expect } from 'vitest'
import type { SnapshotSerializer } from 'vitest'
/** One scoped class token: `_<local>_<hash>` (local may itself contain underscores). */
const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/
/** Fold scoped tokens in one class attribute value; foreign tokens pass through. */
function normalizeClassValue(value: string): string {
return value
.split(/\s+/)
.filter(token => token !== '')
.map(token => token.replace(SCOPED_CLASS, '$1'))
.join(' ')
}
/** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */
function fingerprint(markup: string): string {
let hash = 0x811c9dc5
for (let i = 0; i < markup.length; i++) {
hash ^= markup.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
/** svg elements of a subtree, the root included when it is one. */
function svgsOf(root: Element): Element[] {
const svgs: Element[] = [...root.querySelectorAll('svg')]
if (root.tagName.toLowerCase() === 'svg') svgs.unshift(root)
return svgs
}
/** Whether serializing this subtree needs a normalized clone. */
function needsNormalization(root: Element): boolean {
const scoped = [root, ...root.querySelectorAll('[class]')].some((el) => {
const value = el.getAttribute('class')
return value !== null && value.split(/\s+/).some(token => SCOPED_CLASS.test(token))
})
return scoped || svgsOf(root).some(svg => svg.childNodes.length > 0)
}
/**
* The serializer plugin. Matches DOM elements whose subtree carries a scoped
* class or svg internals; serializes a normalized clone, which no longer
* matches, so printing falls through to the built-in DOM element serializer.
*/
export const domSnapshotSerializer: SnapshotSerializer = {
test(value: unknown): boolean {
return typeof Element !== 'undefined' && value instanceof Element && needsNormalization(value)
},
serialize(value, config, indentation, depth, refs, printer): string {
const clone = (value as Element).cloneNode(true) as Element
for (const el of [clone, ...clone.querySelectorAll('[class]')]) {
const raw = el.getAttribute('class')
if (raw !== null) el.setAttribute('class', normalizeClassValue(raw))
}
for (const svg of svgsOf(clone)) {
if (svg.childNodes.length === 0) continue
svg.setAttribute('data-content', fingerprint(svg.innerHTML))
svg.replaceChildren()
}
return printer(clone, config, indentation, depth, refs)
},
}
let registered = false
/**
* Register {@link domSnapshotSerializer} with vitest's expect (idempotent).
* SlotTestRuntime.create() calls this; specs that snapshot DOM outside the
* runtime import and call it themselves.
*/
export function registerDomSnapshotSerializer(): void {
if (registered) return
registered = true
expect.addSnapshotSerializer(domSnapshotSerializer)
}

View File

@@ -0,0 +1,147 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { workspaceListState } from './fixtures.ts'
import type { Stabilizer } from './fixtures.ts'
/**
* Workspaces test double. Implements the same IWorkspaces face features
* receive as `ctx.workspaces`, so a production face change breaks this
* double at compile time. Every action records into {@link
* TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
* richer behavior replace them via {@link TestWorkspaces.stub}.
*/
export class TestWorkspaces implements IWorkspaces {
/** The useWorkspaces standard feed. */
readonly list: SnapshotStore<WorkspaceListState>
/** Calls observed on the action face, newest last. */
readonly calls: { method: string; args: unknown[] }[] = []
/** Replaceable action seat: feature tests may stub richer behavior. */
private readonly stubs = new Map<string, (...args: unknown[]) => unknown>()
/**
* @param stabilize - the owning runtime's act wrapper.
*/
constructor(private readonly stabilize: Stabilizer) {
this.list = createSnapshotStore<WorkspaceListState>(workspaceListState())
}
/**
* Update the workspace list state through an immer draft.
* @param mutate - draft mutator.
*/
async update(mutate: (draft: WorkspaceListState) => void): Promise<void> {
await this.stabilize(() => { this.list.update(mutate) })
}
/**
* Replace an action's behavior (the recorded call is still appended first).
* @param method - action name (e.g. 'connectWorkspace').
* @param impl - replacement behavior.
*/
stub(method: string, impl: (...args: unknown[]) => unknown): void {
this.stubs.set(method, impl)
}
/**
* Connect a workspace to its reusable/new blank session (recorded). The
* default resolves the workspace id back as the session id; stub for
* cross-session flows.
* @param workspaceId - target workspace.
* @returns the connected session id.
*/
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
this.calls.push({ method: 'connectWorkspace', args: [workspaceId] })
const stub = this.stubs.get('connectWorkspace')
if (stub !== undefined) return await (stub(workspaceId) as Promise<SessionId>)
return `session-of-${workspaceId}` as SessionId
}
/**
* New-session flow (recorded; stubbed behavior runs when installed).
* @param workspaceId - optional explicit workspace target.
*/
startSession(workspaceId?: WorkspaceId): void {
this.calls.push({ method: 'startSession', args: [workspaceId] })
this.stubs.get('startSession')?.(workspaceId)
}
/**
* Create a Workspace (recorded). The default echoes a view derived from
* the input; stub for failure or list-coupled flows.
* @param input - exactly one Host create spelling.
* @returns the created Workspace view.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
this.calls.push({ method: 'create', args: [input] })
const stub = this.stubs.get('create')
if (stub !== undefined) return await (stub(input) as Promise<WorkspaceView>)
const title = 'name' in input ? input.name : input.path
return {
workspaceId: `ws-${title}` as WorkspaceId,
title,
path: 'path' in input ? input.path : `/${input.name}`,
sessionIds: [],
} as unknown as WorkspaceView
}
/**
* Open a path with the host OS default application (recorded; default no-op).
* @param path - host-resolvable path.
*/
async openPath(path: string): Promise<void> {
this.calls.push({ method: 'openPath', args: [path] })
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
}
/**
* Directory picker (recorded). The default cancels (null); stub to select.
* @returns the picked path, or null.
*/
async pickDirectory(): Promise<string | null> {
this.calls.push({ method: 'pickDirectory', args: [] })
const stub = this.stubs.get('pickDirectory')
if (stub !== undefined) return await (stub() as Promise<string | null>)
return null
}
/**
* Rename a Workspace (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace.
* @param title - new title.
* @returns the updated view.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
this.calls.push({ method: 'rename', args: [workspaceId, title] })
const stub = this.stubs.get('rename')
if (stub !== undefined) return await (stub(workspaceId, title) as Promise<WorkspaceView>)
return { workspaceId, title, path: `/${title}`, sessionIds: [] } as unknown as WorkspaceView
}
/**
* Delete a Workspace (recorded; default no-op).
* @param workspaceId - target workspace.
*/
async delete(workspaceId: WorkspaceId): Promise<void> {
this.calls.push({ method: 'delete', args: [workspaceId] })
await (this.stubs.get('delete')?.(workspaceId) as Promise<void> | undefined)
}
/**
* Move an accounted session (recorded). The default echoes a minimal view.
* @param workspaceId - target workspace.
* @param sessionId - session to move.
* @param beforeSessionId - anchor; omitted appends.
* @returns the updated view.
*/
async insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView> {
this.calls.push({ method: 'insertSessionBefore', args: [workspaceId, sessionId, beforeSessionId] })
const stub = this.stubs.get('insertSessionBefore')
if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise<WorkspaceView>)
return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
}
}

View File

@@ -0,0 +1,37 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`single-slot mounting (declare + renderSlot) > folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone 1`] = `
<div
data-slot="trt.panel"
>
<div
class="frame plain"
>
<span
class="label"
>
styled
</span>
<svg
aria-hidden="true"
data-content="2bfa09dc"
viewBox="0 0 16 16"
/>
</div>
</div>
`;
exports[`single-slot mounting edge arms > serializes childless svg untouched next to scoped classes 1`] = `
<div
data-slot="trt.panel"
>
<div
class="frame"
>
<svg
aria-hidden="true"
viewBox="0 0 1 1"
/>
</div>
</div>
`;

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(TestRuntimeInvariant).await()).resolves.toBeDefined()
})
})

View File

@@ -0,0 +1,546 @@
// @vitest-environment jsdom
/**
* SlotTestRuntime behavior: root declaration + rendering, session
* add/update/switch/remove through the real renderer, shared store identity
* and scope pruning, feature mount/dispose cascade, and runtime disposal
* idempotence. All through the production SlotsService + createSlotRenderer
* stack — this suite is the fixture the migrated feature specs rely on.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots, SessionStandardProps } 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.rows': { kind: 'list'; scope: 'root' }
}
}
afterEach(cleanup)
type FrameProps = PropsRenderSlots<'trt.panel' | 'trt.chat' | 'trt.rows'>
/** Root frame declaring all three suite slots (render sites for each kind). */
function Frame({ renderSlot, SessionProvider }: FrameProps) {
return (
<>
{renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
<SessionProvider empty={() => <i>no session</i>}>
{() => renderSlot('trt.chat', {})}
</SessionProvider>
{renderSlot('trt.rows', {})}
</>
)
}
const CHILDREN = {
'trt.panel': { kind: 'single', scope: 'root' },
'trt.chat': { kind: 'single', scope: 'session' },
'trt.rows': { kind: 'list', scope: 'root' },
} as const
async function runtimeWithFrame() {
const runtime = await SlotTestRuntime.create()
await runtime.root.declare(CHILDREN, Frame)
return runtime
}
describe('root declaration and rendering', () => {
it('renders declared slots through the real renderer: fallback, then a live registration, then unload', async () => {
const runtime = await runtimeWithFrame()
const view = runtime.renderRoot()
expect(view.container.textContent).toContain('no panel')
let dispose = (): void => {}
await runtime.flush() // no-op guard: flush outside mutations is safe
await (async () => {
dispose = runtime.slots.register(
{ name: 'trt.panel' },
({ label }: { label?: string }) => <b>panel:{label}</b>)
await runtime.flush()
})()
expect(view.container.textContent).toContain('panel:from-owner')
dispose()
await runtime.flush()
expect(view.container.textContent).toContain('no panel')
await runtime.dispose()
})
it('fails loud when rendering with no root declaration (production boot-order check)', async () => {
const runtime = await SlotTestRuntime.create()
expect(() => runtime.renderRoot()).toThrow(/'root' has no registration/)
await runtime.dispose()
})
})
describe('sessions', () => {
it('drives SessionProvider: empty state, current session, switch, live snapshot updates', async () => {
const runtime = await runtimeWithFrame()
runtime.slots.register({ name: 'trt.chat' }, (props: SessionStandardProps) => {
const running = props.useSession(s => s.running)
return <span>chat:{props.sessionId}:{String(running)}</span>
})
const view = runtime.renderRoot()
expect(view.container.textContent).toContain('no session')
await runtime.sessions.add({ id: 's1' })
expect(view.container.textContent).toContain('chat:s1:false')
await runtime.sessions.updateSnapshot('s1', (draft) => { draft.running = true })
expect(view.container.textContent).toContain('chat:s1:true')
await runtime.sessions.add({ id: 's2' }) // becomes current by default
expect(view.container.textContent).toContain('chat:s2:false')
await runtime.sessions.setCurrent(undefined)
expect(view.container.textContent).toContain('no session')
await runtime.sessions.setCurrent('s1')
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 () => {
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')
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/)
await expect(runtime.sessions.updateSnapshot('ghost', () => {})).rejects.toThrow(/not added/)
await expect(runtime.sessions.remove('ghost')).rejects.toThrow(/not added/)
expect(() => runtime.sessions.behavior('ghost')).toThrow(/not added/)
await runtime.dispose()
})
it('mints REAL-tag scopes lazily and resolves them through the production scopeOf; bindings expose the behavior face', async () => {
const runtime = await runtimeWithFrame()
const prompt = vi.fn()
await runtime.sessions.add({ id: 's1', session: { prompt } })
expect(runtime.sessions.provideInfo('ghost')).toBeUndefined()
expect(runtime.sessions.scope('ghost')).toBeUndefined()
expect(runtime.sessions.binding('ghost')).toBeUndefined()
const scope = runtime.sessions.scope('s1')!
expect(runtime.sessions.scope('s1')).toBe(scope) // stable per session
expect(runtime.sessions.scopeOf(scope)).toBe('s1')
expect(runtime.sessions.scopeOf(runtime.ctx)).toBeUndefined()
// 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 binding = runtime.sessions.binding('s1')!
expect(binding.sessionId).toBe('s1')
expect(binding.ctx).toBe(scope)
;(binding.session as { prompt: () => void }).prompt()
expect(prompt).toHaveBeenCalledOnce()
expect(runtime.sessions.behavior('s1')).toBe(binding.session)
// The binding's session doubles as the conversation observable face.
expect((binding.session as { getSnapshot(): { sessionId: string } }).getSnapshot().sessionId).toBe('s1')
// A scoped service resolves through the scope ctx (scope-addressed pattern).
runtime.provide('probe', { hello: 'world' })
expect(scope.get('probe')).toEqual({ hello: 'world' })
await runtime.dispose()
})
it('materializes provide bundles: built-in session hook, custom providers, no-session projection', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
const info = runtime.sessions.provideInfo('s1')!
expect(info.sessionId).toBe('s1')
expect(info.hooks['session']).toBeDefined() // the built-in useSession source
expect(runtime.sessions.provideInfo('s1')).toBe(info) // identity-stable
// A feature provider (the ui-conversation input pattern): declared names
// materialize per session and land in the no-session roster as undefined.
const off = runtime.sessions.provide({
hooks: ['probe'],
props: ['probeActions'],
resolve: binding => ({
hooks: { probe: { getSnapshot: () => binding.sessionId, subscribe: () => () => {} } },
props: { probeActions: { poke: () => {} } },
}),
})
const enriched = runtime.sessions.provideInfo('s1')!
expect(enriched.hooks['probe']?.getSnapshot()).toBe('s1')
expect(enriched.props['probeActions']).toBeDefined()
const maybe = runtime.sessions.maybeProvideInfo(undefined)
expect(maybe.sessionId).toBeUndefined()
expect(Object.keys(maybe.hooks)).toEqual(['session', 'probe'])
expect(runtime.sessions.maybeProvideInfo('s1')).toBe(runtime.sessions.provideInfo('s1'))
expect(runtime.sessions.maybeProvideInfo('ghost').sessionId).toBeUndefined()
// Misdeclared providers fail loud AT REGISTRATION (the production
// channel rebuilds live bundles eagerly and rolls the roster back):
// missing hook, missing prop, duplicate hook, duplicate prop.
expect(() => runtime.sessions.provide({ hooks: ['void'], resolve: () => ({}) }))
.toThrow(/missing hook "void"/)
expect(() => runtime.sessions.provide({ props: ['void'], resolve: () => ({}) }))
.toThrow(/missing prop "void"/)
expect(() => runtime.sessions.provide({
hooks: ['session'],
resolve: () => ({ hooks: { session: { getSnapshot: () => 0, subscribe: () => () => {} } } }),
})).toThrow(/duplicate hook "session"/)
const propA = runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 1 } }) })
expect(() => runtime.sessions.provide({ props: ['twice'], resolve: () => ({ props: { twice: 2 } }) }))
.toThrow(/duplicate prop "twice"/)
propA()
// The rejected registrations rolled back: the roster still materializes.
expect(runtime.sessions.provideInfo('s1')).toBeDefined()
off()
off() // disposer is idempotent
expect(Object.keys(runtime.sessions.maybeProvideInfo(undefined).hooks)).toEqual(['session'])
await runtime.dispose()
})
it('records service-face calls; open() moves the selection and clear() empties it', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' })
runtime.sessions.open('s1' as SessionId)
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
runtime.sessions.clear()
await runtime.flush()
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
expect(runtime.sessions.calls).toEqual([
{ method: 'open', args: ['s1'] },
{ method: 'clear', args: [] },
])
await runtime.dispose()
})
})
describe('stores', () => {
const createSuiteStore = () => defineStore({
init: () => ({ note: '' }),
persist: 'trt.store',
actions: { setNote: (d, note: string) => { d.note = note } },
})
it('resolves per-session instances via the host face: shared identity, isolation, action-driven re-render', async () => {
const runtime = await runtimeWithFrame()
const handle = createSuiteStore()
runtime.slots.register(
{ name: 'trt.chat', store: handle },
(props: SessionStandardProps & { useStore: <S>(sel: (s: { note: string }) => S) => S }) =>
<span>note:{props.useStore(s => s.note)}</span>)
const view = runtime.renderRoot()
await runtime.sessions.add({ id: 's1' })
expect(() => runtime.storeOf('trt.panel')).toThrow(/no registration/)
const store = runtime.storeOf('trt.chat', 's1')
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
await runtime.sessions.add({ id: 's2' })
const other = runtime.storeOf('trt.chat', 's2')
expect(other).not.toBe(store)
expect(other.getSnapshot()).toEqual({ note: '' })
await runtime.dispose()
})
it('storeOf guards: before renderRoot, and for storeless entries', async () => {
const runtime = await runtimeWithFrame()
runtime.slots.register({ name: 'trt.panel' }, () => null)
expect(() => runtime.storeOf('trt.panel')).toThrow(/before renderRoot/)
runtime.renderRoot()
expect(() => runtime.storeOf('trt.panel')).toThrow(/declares no store/)
await runtime.dispose()
})
it('remove() prunes the session store scope: persisted state clears, a re-added session starts fresh', async () => {
const runtime = await runtimeWithFrame()
const handle = createSuiteStore()
runtime.slots.register({ name: 'trt.chat', store: handle }, () => null)
runtime.renderRoot()
await runtime.sessions.add({ id: 's1' })
const doomed = runtime.storeOf('trt.chat', 's1')
;(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(runtime.sessions.list.getSnapshot().ids).toEqual([])
expect(runtime.sessions.provideInfo('s1')).toBeUndefined()
await runtime.sessions.add({ id: 's1' })
const reborn = runtime.storeOf('trt.chat', 's1')
expect(reborn).not.toBe(doomed)
expect(reborn.getSnapshot()).toEqual({ note: '' })
await runtime.dispose()
})
it('remove() also disposes a minted scope fiber; removing a non-current session keeps the selection', async () => {
const runtime = await runtimeWithFrame()
await runtime.sessions.add({ id: 's1' })
await runtime.sessions.add({ id: 's2' }, { current: false })
const scope = runtime.sessions.scope('s1')!
await runtime.sessions.remove('s2')
expect(runtime.sessions.list.getSnapshot().current).toBe('s1')
await runtime.sessions.remove('s1')
expect(scope.fiber.uid).toBeNull() // disposed fiber loses its uid
expect(runtime.sessions.list.getSnapshot().current).toBeUndefined()
await runtime.dispose()
})
})
describe('workspaces', () => {
it('feeds useWorkspaces and records/stubs intent actions', async () => {
const runtime = await runtimeWithFrame()
runtime.slots.register(
{ name: 'trt.panel' },
(props: { useWorkspaces: <S>(sel: (s: { phase: string }) => S) => S }) =>
<span>ws:{props.useWorkspaces(s => s.phase)}</span>)
const view = runtime.renderRoot()
expect(view.container.textContent).toContain('ws:ready')
await runtime.workspaces.update((draft) => { draft.phase = 'pending' })
expect(view.container.textContent).toContain('ws:pending')
runtime.workspaces.startSession('w1' as WorkspaceId)
await expect(runtime.workspaces.connectWorkspace('w2' as WorkspaceId)).resolves.toBe('session-of-w2')
expect(runtime.workspaces.calls).toEqual([
{ method: 'startSession', args: ['w1'] },
{ method: 'connectWorkspace', args: ['w2'] },
])
const stub = vi.fn(() => Promise.resolve('other' as never))
runtime.workspaces.stub('connectWorkspace', stub)
await expect(runtime.workspaces.connectWorkspace('w3' as WorkspaceId)).resolves.toBe('other')
expect(stub).toHaveBeenCalledOnce()
await runtime.dispose()
})
})
describe('feature mount and disposal', () => {
it('mounts a plugin on a real fiber; dispose() cascades entries, declared children, and services', async () => {
const runtime = await runtimeWithFrame()
runtime.provide('layout', { openDetails: vi.fn() })
const feature = await runtime.mount({
inject: ['slots', 'layout'],
apply: (ctx: typeof runtime.ctx) => {
ctx.provide('feature-service', { ok: true })
ctx.slots.register({
name: 'trt.rows',
id: 'row-1',
children: { 'trt.rows.hole': { kind: 'single', scope: 'root' } },
} as never, ((props: { renderSlot: (key: string, owner: object) => unknown }) =>
<div data-testid="row">{props.renderSlot('trt.rows.hole', {}) as React.ReactNode}</div>) as never)
},
})
const view = runtime.renderRoot()
expect(view.getByTestId('row')).toBeTruthy()
expect(runtime.ctx.get('feature-service')).toEqual({ ok: true })
expect(runtime.slots.entries('trt.rows')).toHaveLength(1)
await feature.dispose()
await feature.dispose() // idempotent
expect(runtime.slots.entries('trt.rows')).toHaveLength(0)
expect(runtime.slots.spec('trt.rows.hole' as never)).toBeUndefined()
expect(runtime.ctx.get('feature-service')).toBeUndefined()
expect(view.queryByTestId('row')).toBeNull()
await runtime.dispose()
})
it('mount fails loud on missing services instead of suspending forever', async () => {
const runtime = await runtimeWithFrame()
await expect(runtime.mount({ inject: ['slots', 'absent-service'], apply: () => {} }))
.rejects.toThrow(/missing service\(s\) absent-service/)
await runtime.dispose()
})
it('runtime dispose is idempotent, unmounts views, disposes mounted features, and clears persisted state', async () => {
const runtime = await runtimeWithFrame()
const feature = await runtime.mount({
inject: ['slots'],
apply: (ctx: typeof runtime.ctx) => { ctx.slots.register({ name: 'trt.panel' }, () => <b>p</b>) },
})
const view = runtime.renderRoot()
expect(view.container.textContent).toContain('p')
localStorage.setItem('trt.leftover', 'x')
await runtime.dispose()
expect(view.container.innerHTML).toBe('')
expect(feature.fiber.uid).toBeNull()
expect(localStorage.getItem('trt.leftover')).toBeNull()
await runtime.dispose() // idempotent
await expect(runtime.dispose()).resolves.toBeUndefined()
})
})
describe('single-slot mounting (declare + renderSlot)', () => {
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' } })
runtime.slots.register(
{ name: 'trt.panel' },
({ label }: { label?: string }) => <b data-testid="panel">{label ?? 'none'}</b>)
const slot = runtime.renderSlot('trt.panel', { label: 'first' })
expect(slot.container.getAttribute('data-slot')).toBe('trt.panel')
expect(slot.view.getByTestId('panel').textContent).toBe('first')
const panel = slot.view.getByTestId('panel')
slot.update({ label: 'second' })
expect(slot.view.getByTestId('panel').textContent).toBe('second')
// In-place re-render: the element identity survived the owner flip.
expect(slot.view.getByTestId('panel')).toBe(panel)
await runtime.dispose()
})
it('views sibling slots of one tree separately and rejects undeclared keys', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({
'trt.panel': { kind: 'single', scope: 'root' },
'trt.rows': { kind: 'list', scope: 'root' },
})
runtime.slots.register({ name: 'trt.panel' }, () => <b>panel</b>)
runtime.slots.register({ name: 'trt.rows', id: 'r1' }, () => <i>row</i>)
const panel = runtime.renderSlot('trt.panel', {})
const rows = runtime.renderSlot('trt.rows', {})
expect(panel.container.textContent).toBe('panel')
expect(rows.container.textContent).toBe('row')
expect(() => runtime.renderSlot('trt.chat', {})).toThrow(/without declare\(\)/)
await runtime.dispose()
})
it('folds class hashes and collapses svg internals in snapshots, leaving the live DOM alone', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
runtime.slots.register({ name: 'trt.panel' }, () => (
<div className="_frame_a1b2c3 plain">
<span className="_label_ff00aa">styled</span>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M0 0L16 16" fill="currentColor" />
</svg>
</div>
))
const slot = runtime.renderSlot('trt.panel', {})
expect(slot.container).toMatchSnapshot()
// The serializer works on a clone: the live DOM keeps hashes and paths.
expect(slot.container.querySelector('div')!.className).toBe('_frame_a1b2c3 plain')
expect(slot.container.querySelector('svg path')).not.toBeNull()
await runtime.dispose()
})
})
describe('fixture session face', () => {
it('fail-loud stubs name the missing verb; supplied overrides run instead', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.sessions.add({ id: 's1' })
const bare = runtime.sessions.behavior('s1')
expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
await runtime.dispose()
})
it('projections faces are identity-stable per key, read absent, and notify on set', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.sessions.add({ id: 's1' })
const session = runtime.sessions.behavior('s1')
const face = session.projections.faceOf('todos')
expect(session.projections.faceOf('todos')).toBe(face)
expect(face.getSnapshot()).toBeUndefined()
const seen: unknown[] = []
const off = face.subscribe(() => { seen.push(face.getSnapshot()) })
session.projections.set('todos', [1, 2])
expect(seen).toEqual([[1, 2]])
off()
session.projections.set('todos', [3])
expect(seen).toEqual([[1, 2]]) // unsubscribed
// A never-subscribed key sets without listeners (the empty-notify arm).
session.projections.set('untouched', 1)
// The provide bundle hands the same store to the render side.
const info = runtime.sessions.provideInfo('s1')!
expect(info.projections?.faceOf('todos').getSnapshot()).toEqual([3])
// A roster change rebuilds the ALREADY-materialized bundle eagerly
// (production channel semantics: mounted entries must see the provider)
// and skips never-materialized records (they pick the roster up lazily).
await runtime.sessions.add({ id: 's-lazy' }, { current: false })
const offProbe = runtime.sessions.provide({
hooks: ['probe2'],
resolve: () => ({ hooks: { probe2: { getSnapshot: () => 1, subscribe: () => () => {} } } }),
})
const rebuilt = runtime.sessions.provideInfo('s1')!
expect(rebuilt).not.toBe(info)
expect(rebuilt.hooks['probe2']).toBeDefined()
offProbe()
await runtime.dispose()
})
})
describe('workspaces action face', () => {
it('records every IWorkspaces verb with inert defaults and honors stubs', async () => {
const runtime = await SlotTestRuntime.create()
const ws = runtime.workspaces
const created = await ws.create({ name: 'alpha' })
expect(created.title).toBe('alpha')
const registered = await ws.create({ path: '/tmp/beta' })
expect(registered.path).toBe('/tmp/beta')
await expect(ws.pickDirectory()).resolves.toBeNull()
const renamed = await ws.rename('w1' as WorkspaceId, 'Renamed')
expect(renamed.title).toBe('Renamed')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/proj/file.ts')
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
expect(moved.sessionIds).toEqual(['s1'])
expect(ws.calls.map(c => c.method)).toEqual(
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore'])
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
ws.stub('delete', () => Promise.resolve())
ws.stub('openPath', () => Promise.resolve())
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
expect((await ws.create({ name: 'y' })).title).toBe('X')
await expect(ws.pickDirectory()).resolves.toBe('/picked')
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/other')
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
await runtime.dispose()
})
})
describe('single-slot mounting edge arms', () => {
it('renderSlot fails loud after dispose and after an external unmount', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
runtime.slots.register({ name: 'trt.panel' }, () => <b>p</b>)
runtime.renderSlot('trt.panel', {})
// RTL cleanup empties the mounted tree behind the runtime's back: the
// wrapper lookup names the state instead of returning a dead container.
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/)
})
it('serializes childless svg untouched next to scoped classes', async () => {
const runtime = await SlotTestRuntime.create()
await runtime.declare({ 'trt.panel': { kind: 'single', scope: 'root' } })
runtime.slots.register({ name: 'trt.panel' }, () => (
<div className="_frame_a1b2c3">
<svg viewBox="0 0 1 1" aria-hidden="true" />
</div>
))
const slot = runtime.renderSlot('trt.panel', {})
expect(slot.container).toMatchSnapshot()
await runtime.dispose()
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../ui-slots"
},
{
"path": "../web-react"
},
{
"path": "../runtime"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -9,10 +9,10 @@
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
SlashServiceContract, SubmitOutcome,
SubmitOutcome,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandContribution, CommandServiceContract } from './contract.ts'
import type { CommandDescriptor } from './directory.ts'
@@ -46,7 +46,7 @@ export class CommandService extends Service implements CommandServiceContract {
if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
return result.value.commands
})
const slash = ctx.get('slash') as SlashServiceContract | undefined
const slash = ctx.get('slash')
if (slash === undefined) throw new Error('ui-command: slash service unavailable')
ctx.effect(() => slash.registerSource({
trigger: '/',
@@ -292,7 +292,7 @@ export class CommandService extends Service implements CommandServiceContract {
return this.sessions().scope(id)
}
private sessions(): SessionsService {
private sessions(): ISessions {
const sessions = this.ctx.get('sessions')
if (sessions === undefined) throw new Error('ui-command: sessions service unavailable')
return sessions

View File

@@ -1,7 +1,7 @@
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ViewTab } from './contract/views.ts'
import type {
@@ -10,6 +10,7 @@ import type {
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import type { IConversation } from './service.ts'
import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
@@ -24,8 +25,8 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
const conversation = scoped.get('conversation')

View File

@@ -3,10 +3,9 @@
* between the independently implemented skeleton and chat domains; `apply.ts`
* owns their slot assembly.
*/
import type { ConversationService } from './service.ts'
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export type { IConversation } from './service.ts'
export type {
CallId, ChatStoreState, SelectionTarget, ViewTab,
@@ -22,6 +21,7 @@ export type {
declare module 'cordis' {
interface Context {
conversation: ConversationService
/** The outward face only; the concrete service stays inside this plugin. */
conversation: import('./service.ts').IConversation
}
}

View File

@@ -8,9 +8,8 @@
* bail events) and owns the default-sink choreography: every session is a
* real host entity, so the sink is one unconditional prompt path.
*/
import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client'
import type {} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
import { queueReadFaceOf } from '../queue/store.ts'
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
import type { PopupDismissFace } from './facade.ts'
@@ -113,7 +112,7 @@ export class InputHub implements InputService {
* exactly one path; a failed first prompt is an ordinary prompt failure
* (error strip via promptError, draft restored only while untouched).
*/
private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
if (text === '') return
const shell = this.shells.get(session.sessionId)
// Commit, not an editable clear: undo must not resurrect sent content.
@@ -129,7 +128,7 @@ export class InputHub implements InputService {
}
private controller(actx: ClientContext): SlashController | undefined {
const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined
const slash = this.rootCtx.get('slash')
return slash?.sessionOf(actx)
}
@@ -138,7 +137,7 @@ export class InputHub implements InputService {
return command?.popupFor(actx)
}
private sessions(): SessionsService {
private sessions(): ISessions {
const sessions = this.rootCtx.get('sessions')
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
return sessions

View File

@@ -5,7 +5,7 @@
* reference-stable across unrelated snapshot swaps, so this is a pure
* projection — no second store, no copy.
*/
import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client'
import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import type { QueuedMessage } from '../input/contract.ts'
/**
@@ -13,10 +13,10 @@ import type { QueuedMessage } from '../input/contract.ts'
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
* QueuedMessage and the input-contract QueuedMessage are structurally the
* same frozen shape ({key, preview}).
* @param session - the resident session instance.
* @param session - the resident session face.
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
*/
export function queueReadFaceOf(session: Session): ObservableSnapshot<readonly QueuedMessage[]> {
export function queueReadFaceOf(session: SessionFace): ObservableSnapshot<readonly QueuedMessage[]> {
return {
getSnapshot: () => session.getSnapshot().queue,
subscribe: fn => session.subscribe(fn),

View File

@@ -12,24 +12,50 @@ import type { Context } from 'cordis'
// Type-only imports: a plugin-to-plugin value import is a bundle purity
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { InputHub } from './input/hub.ts'
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { InputService } from './input/contract.ts'
/**
* The outward conversation face (`ctx.conversation`): the scope-addressed
* verbs and the input registry other plugins may reach — and exactly what a
* test fake must supply.
*/
export interface IConversation {
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/**
* Send a prompt into the caller scope's session.
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
* @returns completion; business failures reject (and land in promptError).
*/
send(text: string, mode: 'queue' | 'steer'): Promise<void>
/**
* Cancel the scoped session's in-flight turn.
* @returns completion; failures reject as in send.
*/
cancel(): Promise<void>
/**
* Pull one older history page for the scoped session.
* @returns completion of the page pull.
*/
loadOlder(): Promise<void>
}
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
export class ConversationService extends Service implements IConversation {
/** The per-session input machine registry (InputService face, design §5.2). */
readonly input: InputHub
readonly input: InputService
/**
* @param ctx - owning root context (the plugin apply context; the service
* registers itself and follows that fiber's lifetime).
* @param config - the shared InputHub constructed by the plugin apply
* (shared with the slot inject factories); absent = own instance
* (object-layer tests that never touch slots).
* @param config - carries the InputService instance constructed by the
* plugin apply (the same InputHub the slot inject factories close over).
*/
constructor(ctx: Context, config?: { input?: InputHub }) {
constructor(ctx: Context, config: { input: InputService }) {
super(ctx, 'conversation')
this.input = config?.input ?? new InputHub(ctx)
this.input = config.input
}
/**
@@ -57,8 +83,8 @@ export class ConversationService extends Service {
await this.scopedSession('loadOlder').loadOlder()
}
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
/** Resolve the caller scope's session face or throw on root contexts. */
private scopedSession(op: string): SessionFace {
const id = this.scopeId(op)
const binding = this.requireSessions().binding(id)
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
@@ -74,10 +100,9 @@ export class ConversationService extends Service {
return id
}
private requireSessions(): SessionsService {
// ctx.get instead of ctx.sessions: the typed Context merge is suspended
// while the client/host `sessions` declaration collision awaits
// arbitration (see the runtime package's Context merge note).
private requireSessions(): ISessions {
// Strict ctx.get, not the injection proxy: the scope-addressed pattern
// reads the service off whatever context the tracker rebound.
const sessions = this.ctx.get('sessions')
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
return sessions

View File

@@ -1,144 +1,73 @@
// @vitest-environment jsdom
// apply inject factories exercised end to end against the terminal thin
// shape: the conversation surface (views triple, send choreography incl.
// optimistic clear + failure restore THROUGH the declared store actions,
// openDetails = select action + layout orchestration, sessions.open
// navigation), and the closeDetails details surface. Complements
// chat-apply.spec.tsx (registration)
// and selection-survival.spec.ts (store axis). History opening is NOT an
// inject concern anymore — the runtime sessions service opens on watch
// (sessions-service.spec.ts owns that behavior).
// shape: the strict session surface (views triple, draft mirror), the
// provide-channel input face (machine-sink submit choreography incl.
// optimistic clear + failure restore), the resident surface (selectWorkspace
// draft carrying), the composer-bar stop face, openDetails = select action +
// layout orchestration, and the closeDetails details surface. Complements
// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store
// axis). History opening is NOT an inject concern — the runtime sessions
// service opens on watch (sessions-service.spec.ts owns that behavior).
//
// The inject surfaces are read off the ledger entries deliberately (typed at
// this spec's own contract): these cases pin factory choreography the UI
// guards would mask. Rendering-path acceptance lives in
// chat-toolview-slot.spec.tsx.
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type {
SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
afterEach(cleanup)
const ROOT = 'root-1' as SessionId
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
type ChatActions = ChatInstance['actions']
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver) {
recorded.push(prop)
// Reflect.get is typed any; the probe only records property names.
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver)
},
})
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
})()
/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */
function sessionFakeFor() {
return {
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
} satisfies SessionBehaviorOverrides
}
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } },
current: ROOT,
phase: 'ready',
const runtime = await SlotTestRuntime.create()
const sessionFake = sessionFakeFor()
await runtime.sessions.add({
id: ROOT,
summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
session: sessionFake,
})
const sessionFake = {
sessionId: ROOT,
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn(() => Promise.resolve()),
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
// Observable face (the input machine's queue read face rides it).
getSnapshot: () => ({ queue: [] }),
subscribe: () => () => {},
}
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
let scoped = scopes.get(id)
if (scoped === undefined) {
scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: id })
scopes.set(id, scoped)
}
return scoped
}
type TestProvider = {
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
hooks?: Record<string, unknown>
props?: Record<string, unknown>
}
}
const providers: TestProvider[] = []
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = {
list: listStore,
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
scope: (id: SessionId) => mint(id),
provideInfo: () => undefined,
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
scopeOf,
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
const workspaceStore = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const workspacesFake = {
list: workspaceStore,
connectWorkspace: vi.fn(async () => ROOT),
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspacesFake)
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('layout', layoutFake)
ctx.provide('locale', { bind: () => (key: string) => key })
runtime.provide('layout', layoutFake)
// The AppFrame role: the three conversation-package slots must be declared
// by a live entry before apply can contribute into them (the stand-in
// consumes renderSlot to satisfy the declare-means-render check).
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
},
// The AppFrame role: the conversation-package slots must be declared by a
// live entry before apply can contribute into them.
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
}, (_p: { renderSlot?: unknown }) => null)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const feature = await runtime.mount({ inject: [...inject], apply })
// Reach the render-side entry view (inject + store handle) the way the
// renderer does: through the host face.
let host: SlotRendererHost | undefined
slots.install({ renderRoot: (h) => { host = h; return null } })
slots.renderSlot('root', {})
const hostFace = host!
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]!
// The host face (store resolution) exists only inside the installed
// renderer, so materialize it the way the shell does.
runtime.renderRoot()
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
runtime.slots.entries(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
const entry = entryOf('conversation.session')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const instance = runtime.storeOf('conversation.session', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
id, instance.actions)
return { instance, injected }
@@ -154,27 +83,28 @@ async function bench() {
/** Same resolution for the chat entry riding the view ring. */
const chatViewSurface = (id: SessionId) => {
const entry = entryOf('conversation.view')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
id, instance.actions)
return { instance, injected }
}
/** Materialize the input provide contribution the way the runtime does. */
const inputSurface = (id: SessionId) => {
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
const state = contribution.hooks!['input'] as {
const info = runtime.sessions.provideInfo(id)!
const state = info.hooks['input'] as {
getSnapshot: () => { draft: string }
subscribe: (fn: () => void) => () => void
}
const actions = contribution.props!['inputActions'] as {
const actions = info.props['inputActions'] as {
setDraft: (text: string) => void
submit: (mode?: 'queue' | 'steer') => void
}
return { state, actions }
}
return {
ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
runtime, feature, slots: runtime.slots, entryOf,
conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
sessionFake, layoutFake,
}
}
@@ -190,6 +120,7 @@ describe('conversation slot inject surface', () => {
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
})
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
@@ -208,14 +139,14 @@ describe('conversation slot inject surface', () => {
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.setDraft('retry me')
actions.submit('queue')
await vi.waitFor(() => {
expect(state.getSnapshot().draft).toBe('retry me')
})
// Failure landing after new typing: no clobber (restore fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
actions.submit('queue')
actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
@@ -229,23 +160,25 @@ describe('conversation slot inject surface', () => {
expect(mirrored).toEqual(['mirrored text'])
unbind()
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
b.composerSurface(ROOT).stop()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
})
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
const b = await bench()
const entry = b.entryOf('conversation.composer.bar')
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
// Unknown session: sessions.scope answers nothing.
;(b.sessionsFake.scope as unknown) = () => undefined
expect(() => { injectFn(ROOT).stop() }).toThrow(/resolved no scope/)
// A scope minted outside the service tree: no conversation service on it.
const foreign = new Context()
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
expect(() => { injectFn(ROOT).stop() }).toThrow(/unavailable through the session scope/)
// Unknown session: the keyboard face's binding resolution answers nothing.
expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/)
// A scope whose service tree lost 'conversation' (the feature fiber
// unloaded while a retained inject closure re-runs): fails loud too.
const stop = injectFn(ROOT).stop
await b.feature.dispose()
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
await b.runtime.dispose()
})
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
@@ -258,6 +191,7 @@ describe('conversation slot inject surface', () => {
// writes land where the skeleton and details read.
const conv = b.conversationSurface(ROOT)
expect(conv.instance).toBe(instance)
await b.runtime.dispose()
})
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
@@ -265,8 +199,9 @@ describe('conversation slot inject surface', () => {
const { injected } = b.chatViewSurface(ROOT)
injected.openFile('src/a.ts')
await vi.waitFor(() => {
expect(b.workspacesFake.openPath).toHaveBeenCalledWith('/proj/src/a.ts')
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
})
await b.runtime.dispose()
})
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
@@ -274,23 +209,73 @@ describe('conversation slot inject surface', () => {
const { injected } = b.conversationSurface(ROOT)
const resident = b.residentSurface(ROOT)
injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
// Same-session connect (the picked workspace resolves to this session):
// no draft movement, plain re-open.
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
const { state, actions } = b.inputSurface(ROOT)
actions.setDraft('carry me')
void resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
await vi.waitFor(() => {
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2)
})
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
expect(state.getSnapshot().draft).toBe('carry me')
// Cross-session connect: the draft MOVES — the old machine empties, the
// new session's machine receives the text, then navigation lands there.
const OTHER = 'other-1' as SessionId
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
await b.runtime.sessions.add({ id: OTHER }, { current: false })
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
void resident.selectWorkspace('workspace-2' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
})
expect(state.getSnapshot().draft).toBe('')
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
await b.runtime.dispose()
})
it('selectWorkspace edge arms: no-session resident, empty-draft move, connect failure retryable', async () => {
const b = await bench()
// No-session resident (hero before any session): connect resolves and
// navigation proceeds without any draft choreography.
const noSession = b.residentSurface(undefined)
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
void noSession.selectWorkspace('workspace-0' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
})
// Cross-session connect with an EMPTY draft: no move, no clearing.
const OTHER = 'b9-other' as SessionId
await b.runtime.sessions.add({ id: OTHER }, { current: false })
const resident = b.residentSurface(ROOT)
const { state } = b.inputSurface(ROOT)
expect(state.getSnapshot().draft).toBe('')
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
void resident.selectWorkspace('workspace-3' as never)
await vi.waitFor(() => {
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
})
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('')
// Connect failure: the rejection propagates to the caller (the view owns
// the rollback) and no further navigation happens.
const opens = b.runtime.sessions.calls.filter(c => c.method === 'open').length
b.runtime.workspaces.stub('connectWorkspace', () => Promise.reject(new Error('offline')))
await expect(resident.selectWorkspace('workspace-4' as never)).rejects.toThrow('offline')
expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(opens)
await b.runtime.dispose()
})
it('scopedConversation fails loud when the session resolves no scope', async () => {
const b = await bench()
// The chat-view inject resolves the scoped conversation service at inject
// time: an unlisted session hits the scope() === undefined throw directly.
const entry = b.entryOf('conversation.view')
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: unknown) => unknown
expect(() => injectFn('never-listed' as SessionId, {})).toThrow(/resolved no scope/)
await b.runtime.dispose()
})
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
@@ -313,6 +298,7 @@ describe('conversation slot inject surface', () => {
off()
off2()
unsub()
await b.runtime.dispose()
})
})
@@ -325,9 +311,9 @@ describe('details inject surface', () => {
injected.closeDetails()
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
// The shared handle: details resolves the SAME instance conversation writes.
const conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT)
const details = b.hostFace.storeOf(entry, ROOT)
const conv = b.runtime.storeOf('conversation.session', ROOT)
const details = b.runtime.storeOf('details', ROOT)
expect(details).toBe(conv)
await b.runtime.dispose()
})
})

View File

@@ -1,89 +1,53 @@
// @vitest-environment jsdom
// apply wiring: the conversation service provided, the chat view registered
// as the first 'conversation.view' ring entry declaring the keyed toolview
// hole, the three slot registrations land against a root entry's children
// declarations (the AppFrame role), the shared store handle rides all session
// entries, and the bash sample mounts through the load-order seam as a keyed
// entry. Full-chain rendering belongs to the machinery spec
// (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the
// assembly surface.
// hole, the slot registrations land against a root entry's children
// declarations (the AppFrame role), the shared store handle rides all strict
// session entries, and the bash sample + todo row mount through the
// load-order seam as keyed entries. Full-chain rendering belongs to the
// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
// stops at the assembly surface.
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const runtime = await SlotTestRuntime.create()
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
await runtime.sessions.add(
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 },
},
current: undefined,
phase: 'ready',
})
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = {
list: listStore,
binding: vi.fn(),
scope: () => undefined,
provideInfo: () => undefined,
currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: vi.fn(() => () => {}),
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('workspaces', {
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('locale', { bind: () => (key: string) => key })
// Declared by ui-layout's root entry in production; a stand-in root
// occupant declares them here so the contributions land (it consumes
// renderSlot to satisfy the declare-means-render check).
const slots = ctx.get('slots') as SlotsService
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
},
// Declared by ui-layout's root entry in production; the test root declares
// them here so the contributions land.
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
}, (_p: { renderSlot?: unknown }) => null)
const fiber = ctx.plugin({ inject: [...inject], apply })
return { ctx, fiber, slots }
const feature = await runtime.mount({ inject: [...inject], apply })
return { runtime, feature, slots: runtime.slots }
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
describe('apply wiring', () => {
it('provides the conversation service', async () => {
const b = await bench()
await b.fiber.await()
expect(b.ctx.get('conversation')).toBeDefined()
expect(b.runtime.ctx.get('conversation')).toBeDefined()
await b.runtime.dispose()
})
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
const b = await bench()
await b.fiber.await()
const entries = b.slots.entries('conversation.view')
expect(entries.map(e => e.options.id)).toEqual(['chat'])
expect(entries[0]?.options.label).toBe('Chat')
@@ -91,11 +55,11 @@ describe('apply wiring', () => {
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
await b.runtime.dispose()
})
it('occupies the slots + the ring; session entries share one store handle', async () => {
const b = await bench()
await b.fiber.await()
const conversation = renderEntryOf(b.slots, 'conversation')
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
const chatView = renderEntryOf(b.slots, 'conversation.view')
@@ -111,21 +75,21 @@ describe('apply wiring', () => {
// The hero workspace picker hole rides the conversation entry's children
// declaration (the empty-state occupant is gone).
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
await b.runtime.dispose()
})
it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
const b = await bench()
await b.fiber.await()
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
await b.runtime.dispose()
})
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
const b = await bench()
await b.fiber.await()
await b.fiber.dispose()
await b.feature.dispose()
expect(b.slots.entries('conversation')).toHaveLength(0)
// The declared ring collapses with its declaring entry, and the chat
// entry's keyed hole (with the sample's registration) collapses with it.
@@ -133,6 +97,7 @@ describe('apply wiring', () => {
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.ctx.get('conversation')).toBeUndefined()
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
await b.runtime.dispose()
})
})

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
// cordis Context + SlotsService ledger + the web-react renderer + this
// package's own apply — no outlet twins. Proves the keyed
// SlotTestRuntime (cordis Context + SlotsService ledger + the web-react
// renderer) + this package's own apply — no outlet twins. Proves the keyed
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
// entryKey (the bash sample lands through its plugin), unregistered tools
// fall back to GenericToolCard at the render site, live registration/unload
@@ -10,23 +10,16 @@
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
// semantics until the service (and with it the hole declaration) is present.
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { cleanup, fireEvent } from '@testing-library/react'
import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
afterEach(cleanup)
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
@@ -40,117 +33,38 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
content: [], isError: false, callView: null, resultView: null,
})
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
const LAYOUT_CHILDREN = {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
} as const
/**
* Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
* fakes at the service seams only (external boundaries), the package apply on
* its own fiber, and the test AppFrame occupying 'root'.
* Real-stack bench: SlotTestRuntime with the session/layout doubles at the
* service seams only (external boundaries), the package apply on its own
* fiber, and the test AppFrame occupying 'root'.
*/
async function bench(nodes: ToolResultNode[]) {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready',
})
// Identity-stable provide bundle: the renderer caches hooks per source and
// inject results per bundle, both by object identity. Registered providers
// (the package's input contribution) materialize into it lazily, once.
const providers: ((binding: object) => { hooks?: object; props?: object })[] = []
let info: { sessionId: SessionId; hooks: object; props: object } | undefined
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const runtime = await SlotTestRuntime.create()
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} }
const bindingOf = (id: SessionId) => ({
sessionId: id,
ctx: actxFake,
runtime.provide('layout', layout)
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S' },
snapshot: { nodes },
session: {
sessionId: id,
loadOlder: vi.fn(),
prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })),
// Observable face for the input machine's queue read face.
getSnapshot: () => session.getSnapshot(),
subscribe: (fn: () => void) => session.subscribe(fn),
loadOlder: vi.fn<ISession['loadOlder']>(),
prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
},
})
const provideInfo = (id: string) => {
if (id !== SID) return undefined
if (info === undefined) {
const hooks: Record<string, unknown> = { session }
const props: Record<string, unknown> = {}
for (const provider of providers) {
const c = provider(bindingOf(SID))
Object.assign(hooks, c.hooks ?? {})
Object.assign(props, c.props ?? {})
}
info = { sessionId: SID, hooks, props }
}
return info
}
ctx.provide('sessions', {
list,
binding: bindingOf,
scope: () => actxFake,
provideInfo,
currentProvideInfo: {
getSnapshot: () => provideInfo(SID),
subscribe: () => () => {},
},
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
scopeOf: () => SID,
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
const workspaces = {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
}
ctx.provide('workspaces', workspaces)
ctx.provide('layout', layout)
ctx.provide('locale', { bind: () => (key: string) => key })
slots.install(createSlotRenderer())
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
},
}, AppRoot)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, list, layout, workspaces }
}
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
function mountApp(slots: SlotsService) {
return render(<>{slots.renderSlot('root', {})}</>)
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
const feature = await runtime.mount({ inject: [...inject], apply })
return { runtime, slots: runtime.slots, feature, layout }
}
describe('keyed toolview hole through the real machinery', () => {
@@ -159,7 +73,7 @@ describe('keyed toolview hole through the real machinery', () => {
toolResult(3, 'c1', 'bash'),
toolResult(4, 'c2', 'mystery', '{"n":1}'),
])
const view = mountApp(b.slots)
const view = b.runtime.renderRoot()
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
@@ -167,6 +81,7 @@ describe('keyed toolview hole through the real machinery', () => {
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.
expect(view.getByText('Tool call')).toBeTruthy()
await b.runtime.dispose()
})
it('renders top-level Cordis calls with lifecycle titles over the generic variants', async () => {
@@ -176,7 +91,7 @@ describe('keyed toolview hole through the real machinery', () => {
toolResult(4, 'cordis-2', 'cordis_mount', JSON.stringify({ code })),
toolResult(5, 'cordis-3', 'cordis_unmount', '{"id":"dyn-2"}'),
])
const view = mountApp(b.slots)
const view = b.runtime.renderRoot()
expect(view.container.querySelector('[data-tool="cordis_inspect"]')?.textContent).toContain('Inspect')
const mounted = view.container.querySelector('[data-variant="code"]')
@@ -186,42 +101,46 @@ describe('keyed toolview hole through the real machinery', () => {
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
await b.runtime.dispose()
})
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
const view = mountApp(b.slots)
const view = b.runtime.renderRoot()
view.getByText('src/a.ts').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(b.workspaces.openPath).toHaveBeenCalledWith('src/a.ts')
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] })
})
await b.runtime.dispose()
})
it('bash summary clicks do not open details or host paths', async () => {
const b = await bench([toolResult(3, 'c1', 'bash')])
const view = mountApp(b.slots)
const view = b.runtime.renderRoot()
view.getByText('Build').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
expect(b.workspaces.openPath).not.toHaveBeenCalled()
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
await b.runtime.dispose()
})
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
const view = mountApp(b.slots)
const view = b.runtime.renderRoot()
expect(view.getByText('Tool call')).toBeTruthy()
let dispose = (): void => {}
await act(async () => {
dispose = b.slots.register(
{ name: 'conversation.chat.toolview', key: 'mystery' },
() => <div data-testid="mystery-row" />)
})
dispose = b.slots.register(
{ name: 'conversation.chat.toolview', key: 'mystery' },
() => <div data-testid="mystery-row" />)
await b.runtime.flush()
// Per-key version tick: the row flipped without a remount of the view.
expect(view.getByTestId('mystery-row')).toBeTruthy()
expect(view.queryByText('Tool call')).toBeNull()
await act(async () => { dispose() })
dispose()
await b.runtime.flush()
expect(view.queryByTestId('mystery-row')).toBeNull()
expect(view.getByText('Tool call')).toBeTruthy()
await b.runtime.dispose()
})
it('a duplicate key registration fails loud at load', async () => {
@@ -230,6 +149,7 @@ describe('keyed toolview hole through the real machinery', () => {
{ name: 'conversation.chat.toolview', key: 'bash' },
() => null,
)).toThrow(/key "bash"/)
await b.runtime.dispose()
})
it('the inject channel feeds (sessionId) => I into the row component', async () => {
@@ -247,66 +167,33 @@ describe('keyed toolview hole through the real machinery', () => {
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
<button data-testid="probe-row" onClick={poke}>{mark}</button>
))
const view = mountApp(b.slots)
const view = b.runtime.renderRoot()
const row = view.getByTestId('probe-row')
expect(row.textContent).toBe(`for:${SID}`)
row.click()
expect(poked).toEqual([SID])
await b.runtime.dispose()
})
})
describe('registrant load-order seam', () => {
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
}),
binding: () => undefined,
scope: () => undefined,
provideInfo: () => undefined,
currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {},
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('locale', { bind: () => (key: string) => key })
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
},
}, AppRoot)
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
// semantics hold it — apply must not run while 'conversation' is absent.
// (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
// fiber's isConstructor branch.)
// Uses ctx.plugin directly (the deliberate-suspension escape hatch; mount()
// would fail loud on the missing service). (Plain arrow, not vi.fn: mock
// functions carry a prototype and trip the fiber's isConstructor branch.)
let applyRuns = 0
const registrantApply = (registrantCtx: Context): void => {
const registrantApply = (registrantCtx: typeof runtime.ctx): void => {
applyRuns += 1
registrantCtx.slots.register(
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
}
const late = ctx.plugin({
const late = runtime.ctx.plugin({
name: 'late-registrant',
inject: ['slots', 'conversation'],
apply: registrantApply,
@@ -317,11 +204,11 @@ describe('registrant load-order seam', () => {
// Mounting the package resolves the seam: service present ⟹ the chat
// entry (and its hole declaration) is already on the ledger, so the
// suspended registrant lands without an undeclared-slot throw.
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await runtime.mount({ inject: [...inject], apply })
await late.await()
expect(applyRuns).toBe(1)
expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key))
.toEqual(expect.arrayContaining(['bash', 'late']))
await runtime.dispose()
})
})

View File

@@ -1,149 +0,0 @@
// @vitest-environment jsdom
/**
* Exercises selection persistence through the real SlotsService store axis;
* component stubs cannot prove per-session identity or disposal.
*/
import { Context } from 'cordis'
import { beforeEach, describe, expect, it } from 'vitest'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts'
const sid = (s: string): SessionId => s as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
interface Bench {
slots: SlotsService
chat: ReturnType<typeof createChatStore>
}
function bench(): Bench {
const ctx = new Context()
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
}),
provideInfo: () => undefined,
currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {},
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
})
// Service self-registers as ctx 'slots' (cordis Service constructor).
const slots = new SlotsService(ctx)
const chat = createChatStore()
// The apply.ts shape: one shared handle across both session-slot
// registrations. 'conversation'/'details' must first exist in the ledger —
// register a root occupant declaring them (the AppFrame role; the stand-in
// consumes renderSlot to satisfy the declare-means-render check).
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session-maybe' },
'conversation.session': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
},
}, (_p: { renderSlot?: unknown }) => null)
// apply.ts mounts the shared chat handle only under session-scope slots
// (the session-maybe 'conversation' shell carries no store).
slots.register({ name: 'conversation.session', store: chat }, () => null)
slots.register({ name: 'details', store: chat }, () => null)
return { slots, chat }
}
/** Resolve the store instance the renderer would hand a slot's component for a session. */
function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) {
const host = renderHost(b)
const entry = host.entriesOf(slot)[0]!
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
}
/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
if (captured._host === undefined) {
b.slots.install({
renderRoot: (host) => {
captured._host = host
return null
},
})
b.slots.renderSlot('root', {})
}
return captured._host!
}
beforeEach(() => {
localStorage.clear()
})
describe('selection survives on the store seat', () => {
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
const b = bench()
const conv = storeFor(b, 'conversation.session', sid('s1'))
const details = storeFor(b, 'details', sid('s1'))
conv.actions.select({ turnSeq: 3, callId: 'c1' })
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
// Identity, not just value: the shared handle resolves one instance per scope key.
expect(details).toBe(conv)
})
it('sessions are isolated: s2 selection never bleeds into s1', () => {
const b = bench()
const one = storeFor(b, 'conversation.session', sid('s1'))
const two = storeFor(b, 'conversation.session', sid('s2'))
expect(two).not.toBe(one)
one.actions.select({ turnSeq: 1, callId: 'a' })
two.actions.select({ turnSeq: 9, callId: 'z' })
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
})
it('a list-projection update keeps instance identity and the selection value', () => {
const b = bench()
const id = sid('s1')
const projection = createSnapshotStore({ displayTitle: 's1' })
const store = storeFor(b, 'conversation.session', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
projection.set({ displayTitle: 'proj-a' })
expect(projection.getSnapshot().displayTitle).toBe('proj-a')
const after = storeFor(b, 'conversation.session', id)
expect(after).toBe(store)
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().draft).toBe('half-typed')
})
it('session death buries the instance and its persisted draft', () => {
const b = bench()
const doomed = storeFor(b, 'conversation.session', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
// SessionsService calls this public slot lifecycle seam when the scope dies.
b.slots.pruneStoreScope(sid('s1'))
// Persisted residue is gone with the session...
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
// ...and a re-created same-id session starts from a FRESH instance.
const reborn = storeFor(b, 'conversation.session', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
})
})

View File

@@ -0,0 +1,109 @@
// @vitest-environment jsdom
/**
* Exercises selection persistence through the real SlotsService store axis;
* component stubs cannot prove per-session identity or disposal.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { createChatStore } from '../src/client/stores.ts'
const sid = (s: string): SessionId => s as SessionId
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
async function bench() {
const runtime = await SlotTestRuntime.create()
const chat = createChatStore()
// The apply.ts shape: one shared handle across both strict-session slot
// registrations ('conversation.session'/'details'); the session-maybe
// 'conversation' shell carries no store by design. The slots must first
// exist in the ledger — the test root declares them (the AppFrame role).
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'conversation.session': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
}, (_p: { renderSlot?: unknown }) => null)
runtime.slots.register({ name: 'conversation.session', store: chat }, () => null)
runtime.slots.register({ name: 'details', store: chat }, () => null)
runtime.renderRoot() // materializes the host face storeOf resolves through
return { runtime, chat }
}
/** Resolve the store instance the renderer would hand a slot's component for a session. */
function storeFor(b: Awaited<ReturnType<typeof bench>>, slot: 'conversation.session' | 'details', sessionId: SessionId) {
return b.runtime.storeOf(slot, sessionId) as ChatInstance
}
beforeEach(() => {
localStorage.clear()
})
describe('selection survives on the store seat', () => {
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
const b = await bench()
const conv = storeFor(b, 'conversation.session', sid('s1'))
const details = storeFor(b, 'details', sid('s1'))
conv.actions.select({ turnSeq: 3, callId: 'c1' })
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
// Identity, not just value: the shared handle resolves one instance per scope key.
expect(details).toBe(conv)
await b.runtime.dispose()
})
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
const b = await bench()
const one = storeFor(b, 'conversation.session', sid('s1'))
const two = storeFor(b, 'conversation.session', sid('s2'))
expect(two).not.toBe(one)
one.actions.select({ turnSeq: 1, callId: 'a' })
two.actions.select({ turnSeq: 9, callId: 'z' })
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
await b.runtime.dispose()
})
it('a list-projection update keeps instance identity and the selection value', async () => {
const b = await bench()
const id = sid('s1')
const store = storeFor(b, 'conversation.session', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
// A projection churn elsewhere (list rows re-projected) must not touch
// store identity: drive the runtime's own list observable.
await b.runtime.sessions.add({ id, summary: { displayTitle: 'proj-a' } })
expect(b.runtime.sessions.list.getSnapshot().byId[id]?.displayTitle).toBe('proj-a')
const after = storeFor(b, 'conversation.session', id)
expect(after).toBe(store)
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().draft).toBe('half-typed')
await b.runtime.dispose()
})
it('session death buries the instance and its persisted draft', async () => {
const b = await bench()
await b.runtime.sessions.add({ id: 's1' })
const doomed = storeFor(b, 'conversation.session', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
// TestSessions.remove drives the same public slot lifecycle seam the
// production SessionsService calls when the scope dies (pruneStoreScope).
await b.runtime.sessions.remove('s1')
// Persisted residue is gone with the session...
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
// ...and a re-created same-id session starts from a FRESH instance.
const reborn = storeFor(b, 'conversation.session', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
await b.runtime.dispose()
})
})

View File

@@ -1,39 +1,32 @@
// @vitest-environment jsdom
// ConversationService scope addressing over the runtime's real scope tag:
// TestSessions mints tagged scopes through the production createScope, so the
// service's scopeOf/binding path runs against production resolution (no local
// tag probe).
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { InputHub } from '../src/client/input/hub.ts'
const sid = (id: string) => id as SessionId
const SCOPE_TAG: symbol = (() => {
const reads: (string | symbol)[] = []
const proxy = new Proxy(new Context(), {
get(target, property, receiver): unknown {
reads.push(property)
return Reflect.get(target, property, receiver)
},
})
void scopeOf(proxy)
return reads.find((value): value is symbol => typeof value === 'symbol')!
})()
async function bench(withSessions = true) {
const ctx = new Context()
async function bench() {
const runtime = await SlotTestRuntime.create()
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const loadOlder = vi.fn(() => Promise.resolve())
const sessions = {
binding: (sessionId: SessionId) => ({
sessionId, session: { prompt, cancel, loadOlder },
}),
scopeOf,
} as unknown as SessionsService
if (withSessions) ctx.provide('sessions', sessions)
await ctx.plugin(ConversationService).await()
const root = ctx.get('conversation') as ConversationService
const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService
return { root, scoped, prompt, cancel, loadOlder }
await runtime.sessions.add({
id: 's1',
session: { prompt, cancel, loadOlder },
})
// config.input is required (the apply shares its hub with the inject
// factories); the bench passes its own instance explicitly.
const fiber = runtime.ctx.plugin(ConversationService, {
input: new InputHub(runtime.ctx),
})
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
return { runtime, root, scoped, prompt, cancel, loadOlder }
}
describe('ConversationService', () => {
@@ -45,6 +38,7 @@ describe('ConversationService', () => {
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
await b.runtime.dispose()
})
it('folds Session business failures into callback rejections', async () => {
@@ -53,12 +47,21 @@ describe('ConversationService', () => {
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
await b.runtime.dispose()
})
it('fails loudly from the root scope or without SessionsService', async () => {
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
const b = await bench()
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
const missing = await bench(false)
await expect(missing.root.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
await b.runtime.sessions.remove('s1')
await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/)
await b.runtime.dispose()
// No SessionsService at all: a bare context (the runtime always provides one).
const bare = new Context()
await bare.plugin(ConversationService, {
input: new InputHub(bare),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
})
})

View File

@@ -17,14 +17,16 @@ import { ThemePresenter } from './theme-presenter.ts'
// Contract surface only (export-convergence rule: cross-package consumers
// keep a symbol exported; test-only/package-internal symbols live off /src).
// LayoutService: the ctx.layout service class (consumers type against it).
// ILayout: the ctx.layout face consumers and test fakes type against.
// OwnerShare contracts below are the render-side halves registrants compose
// against; the frame components and the store factory are package-internal.
export { LayoutService } from './service.ts'
export type { ILayout } from './service.ts'
declare module 'cordis' {
interface Context {
layout: LayoutService
/** The outward face only; the concrete service stays inside this plugin. */
layout: import('./service.ts').ILayout
}
}

View File

@@ -14,8 +14,23 @@ import type { createLayoutStore } from './stores.ts'
/** The layout store's bound action set (framework-baked, draft params peeled). */
export type PanelActions = BoundActions<ReturnType<typeof createLayoutStore>>
/**
* The outward layout face (`ctx.layout`): the panel transitions other
* plugins may trigger — and exactly what a test fake must supply. The
* attachPanels wiring hook stays on the concrete class (root-entry assembly
* only).
*/
export interface ILayout {
/** Toggle the sidebar panel (closed ⟷ contract default width). */
toggleSidebar(): void
/** Open the details panel (no-op when already open). */
openDetails(): void
/** Close the details panel. */
closeDetails(): void
}
/** Cross-plugin panel-action face (ctx.layout). */
export class LayoutService {
export class LayoutService implements ILayout {
#panels: PanelActions | undefined
/**

View File

@@ -47,6 +47,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",

View File

@@ -0,0 +1,131 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`sidebar shell snapshots > renders the collapsed rail after the crossfade settles, in place 1`] = `
<div
data-slot="sidebar"
>
<div
class="root collapsed railIn"
style=""
>
<div
class="logoRow"
>
<button
aria-label="Open sidebar"
class="iconButton toggle"
type="button"
>
<svg
aria-hidden="true"
class="railFish"
data-content="965fe321"
fill="none"
height="17.6580310880829"
viewBox="0 0 23.16 17.04"
width="24"
/>
<svg
class="panelIcon"
data-content="35f95b0c"
fill="none"
height="18"
viewBox="0 0 16 16"
width="18"
xmlns="http://www.w3.org/2000/svg"
/>
</button>
</div>
<button
aria-label="New session"
class="newSession"
type="button"
>
<svg
data-content="c0ce4dcc"
fill="none"
height="18"
viewBox="0 0 16 16"
width="18"
xmlns="http://www.w3.org/2000/svg"
/>
</button>
<div
class="regionArea"
/>
<div
class="footArea"
/>
</div>
</div>
`;
exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsule, empty holes) 1`] = `
<div
data-slot="sidebar"
>
<div
class="root"
style="width: 300px;"
>
<div
class="logoRow"
>
<button
aria-label="New session"
class="brand wide"
type="button"
>
<svg
aria-hidden="true"
data-content="951274b9"
fill="none"
height="24"
viewBox="0 0 182 24"
width="182"
/>
</button>
<button
aria-label="Collapse sidebar"
class="iconButton toggle"
type="button"
>
<svg
class="panelIcon"
data-content="35f95b0c"
fill="none"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
/>
</button>
</div>
<button
aria-label="New session"
class="newSession"
type="button"
>
<svg
data-content="c0ce4dcc"
fill="none"
height="14"
viewBox="0 0 16 16"
width="14"
xmlns="http://www.w3.org/2000/svg"
/>
<span
class="newSessionLabel wide"
>
New Session
</span>
</button>
<div
class="regionArea"
/>
<div
class="footArea"
/>
</div>
</div>
`;

View File

@@ -0,0 +1,51 @@
// @vitest-environment jsdom
/**
* Local DOM snapshots of the sidebar shell through the real assembly path:
* SlotTestRuntime mounts the package apply on its own fiber, the auto frame
* supplies the layout's owner share at the render site, and the snapshot
* captures exactly the 'sidebar' slot's output (CSS-module class names
* folded to their semantic locals by the runtime's serializer). The child
* holes (sidebar.workspaces / sidebar.settings) have no registrant here, so
* the snapshots pin the shell chrome itself.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, waitFor } from '@testing-library/react'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
afterEach(cleanup)
async function bench() {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { toggleSidebar: vi.fn() })
await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
await runtime.mount({ inject: [...inject], apply })
return runtime
}
describe('sidebar shell snapshots', () => {
it('renders the expanded column (wordmark, capsule, empty holes)', async () => {
const runtime = await bench()
const slot = runtime.renderSlot('sidebar', { collapsed: false, width: 300 })
// Wordmark + capsule both start a session in the expanded state.
expect(slot.view.getAllByRole('button', { name: 'New session' })).toHaveLength(2)
expect(slot.container).toMatchSnapshot()
await runtime.dispose()
})
it('renders the collapsed rail after the crossfade settles, in place', async () => {
const runtime = await bench()
const slot = runtime.renderSlot('sidebar', { collapsed: false, width: 300 })
const shell = slot.container.firstElementChild
slot.update({ collapsed: true, width: 56 })
// The wide content (wordmark shortcut) unmounts at the 150ms settle;
// only the rail's capsule remains a New-session button.
await waitFor(() => {
expect(slot.view.getAllByRole('button', { name: 'New session' })).toHaveLength(1)
})
expect(slot.container).toMatchSnapshot()
// Same tree position: the owner flip re-rendered the shell in place.
expect(slot.container.firstElementChild).toBe(shell)
await runtime.dispose()
})
})

View File

@@ -24,7 +24,8 @@ export type { SlashServiceContract } from './contract.ts'
declare module 'cordis' {
interface Context {
slash: SlashService
/** The outward face only; the concrete service stays inside this plugin. */
slash: import('./contract.ts').SlashServiceContract
}
}

View File

@@ -7,7 +7,7 @@
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
import type { ClientContext, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashSource } from '../types.ts'
import { SlashController } from './controller.ts'
import type { SlashServiceContract } from './contract.ts'
@@ -99,7 +99,7 @@ export class SlashService extends Service implements SlashServiceContract {
return controller
}
private sessions(): SessionsService {
private sessions(): ISessions {
const sessions = this.ctx.get('sessions')
if (sessions === undefined) throw new Error('ui-slash: sessions service unavailable')
return sessions

View File

@@ -31,6 +31,7 @@
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",

View File

@@ -0,0 +1,61 @@
// @vitest-environment jsdom
/**
* App-shell assembly plugin on the real machinery: bare Context + production
* SlotsService + the test-runtime session/workspace doubles. Deliberately NOT
* mounted through SlotTestRuntime — its create() installs the capturing
* renderer and install() is boot-once; app-shell IS the production installer,
* so this bench hands it the uninstalled service exactly as boot does.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { Context } from 'cordis'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime'
import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime'
import * as AppShell from '@deepseek-ai/dsh-client-web/src/app-shell.ts'
afterEach(cleanup)
const stabilize: Stabilizer = async (fn) => { await act(async () => { await fn() }) }
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
ctx.provide('sessions', new TestSessions(stabilize, ctx))
ctx.provide('workspaces', new TestWorkspaces(stabilize))
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const fiber = ctx.plugin({ inject: [...AppShell.inject], apply: AppShell.apply })
await fiber.await()
return { ctx, slots, fiber }
}
describe('app-shell assembly plugin', () => {
it('installs the renderer and provides the assembled appShell face', async () => {
const { ctx, slots } = await bench()
slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
const shell = ctx.get('appShell')
expect(shell).toBeDefined()
const view = render(<>{shell!.renderApp()}</>)
expect(view.getByTestId('root-probe')).toBeTruthy()
})
it('assembles once: repeated renderApp calls reuse the built closure', async () => {
const { ctx, slots } = await bench()
slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
const shell = ctx.get('appShell')!
const first = render(<>{shell.renderApp()}</>)
expect(first.getByTestId('root-probe')).toBeTruthy()
first.unmount()
// Second call rides the cached closure (renderApp ??=) and still renders.
const second = render(<>{shell.renderApp()}</>)
expect(second.getByTestId('root-probe')).toBeTruthy()
})
it('fiber dispose retracts the service and uninstalls the renderer', async () => {
const { ctx, slots, fiber } = await bench()
await stabilize(() => fiber.dispose())
expect(ctx.get('appShell')).toBeUndefined()
expect(() => slots.renderSlot('root', {})).toThrow('not installed')
})
})

View File

@@ -0,0 +1,65 @@
// @vitest-environment jsdom
/**
* buildRenderApp on SlotTestRuntime: the fail-loud sessions precondition, the
* one ctx-level renderSlot('root') call, and the document-title projection
* arms over the real slot stack.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { Context } from 'cordis'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { buildRenderApp } from '@deepseek-ai/dsh-client-web/src/app.tsx'
let runtime: SlotTestRuntime | undefined
afterEach(async () => {
cleanup()
await runtime?.dispose()
runtime = undefined
document.title = ''
})
async function bench() {
runtime = await SlotTestRuntime.create()
await runtime.root.declare({}, () => <div data-testid="frame" />)
return { runtime, renderApp: buildRenderApp({ ctx: runtime.ctx }) }
}
describe('buildRenderApp', () => {
it('fails loud when the sessions service is unavailable', () => {
expect(() => buildRenderApp({ ctx: new Context() })).toThrow('sessions service unavailable')
})
it('renders the root slot tree through the one ctx-level renderSlot call', async () => {
const b = await bench()
const view = render(<>{b.renderApp()}</>)
expect(view.getByTestId('frame')).toBeTruthy()
})
it('projects the current session durable title and falls back to the product title', async () => {
document.title = 'Product'
const b = await bench()
render(<>{b.renderApp()}</>)
// No current session: the product title stands.
expect(document.title).toBe('Product')
await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
expect(document.title).toBe('First — Product')
await b.runtime.sessions.setCurrent(undefined)
expect(document.title).toBe('Product')
// A session without a durable title keeps the product title.
await b.runtime.sessions.add({ id: 's2' })
expect(document.title).toBe('Product')
})
it('a current id without a list row falls back (selection/list arbitration transient)', async () => {
document.title = 'Product'
const b = await bench()
await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
render(<>{b.renderApp()}</>)
expect(document.title).toBe('First — Product')
b.runtime.sessions.list.update((draft) => { draft.current = 'ghost' as SessionId })
await b.runtime.flush()
expect(document.title).toBe('Product')
})
})