From 0efc7f045ec80e04a22f6e952d076b2879fa37cb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:03:56 +0800 Subject: [PATCH] refactor(client): carve outward interfaces for the session, workspace, layout, slash, and conversation services Feature packages now reach these domains through interface types only: ISession/SessionFace (identity + prompt/cancel/loadOlder + the useSession snapshot source), ISessions, IWorkspaces, ILayout, IConversation, and the existing SlashServiceContract now actually mounted on Context.slash. The concrete services implement their face; wire-pump and assembly entry points stay on the classes. The provide-channel materialization and current-projection logic moves into SessionProvideChannel so the production service and the client test runtime share one implementation. The workspaces service consumes sessions through the narrow SessionsPort. --- .../runtime/src/client/contract/session.ts | 56 ++++++ .../src/client/contract/sessions-port.ts | 47 +++++ .../runtime/src/client/contract/sessions.ts | 64 ++++++ .../runtime/src/client/contract/workspaces.ts | 65 ++++++ packages/client/runtime/src/client/index.ts | 13 +- .../runtime/src/client/sessions/provide.ts | 190 ++++++++++++++++++ .../runtime/src/client/sessions/service.ts | 158 +++------------ .../runtime/src/client/sessions/session.ts | 8 +- .../runtime/src/client/workspaces/service.ts | 11 +- .../client/ui-command/src/client/service.ts | 8 +- .../ui-conversation/src/client/apply.ts | 7 +- .../ui-conversation/src/client/index.ts | 6 +- .../ui-conversation/src/client/input/hub.ts | 11 +- .../ui-conversation/src/client/queue/store.ts | 6 +- .../ui-conversation/src/client/service.ts | 55 +++-- packages/client/ui-layout/src/client/index.ts | 6 +- .../client/ui-layout/src/client/service.ts | 17 +- packages/client/ui-slash/src/client/index.ts | 3 +- .../client/ui-slash/src/client/service.ts | 4 +- 19 files changed, 556 insertions(+), 179 deletions(-) create mode 100644 packages/client/runtime/src/client/contract/session.ts create mode 100644 packages/client/runtime/src/client/contract/sessions-port.ts create mode 100644 packages/client/runtime/src/client/contract/sessions.ts create mode 100644 packages/client/runtime/src/client/contract/workspaces.ts create mode 100644 packages/client/runtime/src/client/sessions/provide.ts diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts new file mode 100644 index 0000000000..2ada5e6fbc --- /dev/null +++ b/packages/client/runtime/src/client/contract/session.ts @@ -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 +} + +/** 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> + /** + * Cancel the running turn. + * @returns acceptance, or the business error. + */ + cancel(): Promise> + /** + * Extend the history window backwards (older messages pagination). + * @returns completion; failures land in snapshot.openState/loadingOlder. + */ + loadOlder(): Promise +} + +/** + * 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 diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts new file mode 100644 index 0000000000..466e26fe12 --- /dev/null +++ b/packages/client/runtime/src/client/contract/sessions-port.ts @@ -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 + 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 + /** + * Create a session on the host. + * @param opts - target workspace. + * @returns the new session id. + */ + create(opts: { workspaceId: WorkspaceId }): Promise + /** + * 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 +} diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts new file mode 100644 index 0000000000..d26b392072 --- /dev/null +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -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 + /** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */ + readonly currentProvideInfo: HostObservable + /** + * 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` + * 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 +} diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts new file mode 100644 index 0000000000..cd32cbf56f --- /dev/null +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -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 + /** + * Connect a Workspace to its reusable or freshly created blank session. + * @param workspaceId - target workspace. + * @returns the connected session id. + */ + connectWorkspace(workspaceId: WorkspaceId): Promise + /** + * 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 + /** + * Open the Host's native directory picker. + * @returns the selected path, or null when the user cancelled. + */ + pickDirectory(): Promise + /** + * Open a filesystem path with the Host operating system's default application. + * @param path - absolute or host-resolvable path. + */ + openPath(path: string): Promise + /** + * Rename a Workspace. + * @param workspaceId - target workspace. + * @param title - the new display title. + * @returns the updated Workspace view. + */ + rename(workspaceId: WorkspaceId, title: string): Promise + /** + * Delete a Workspace (its sessions fall back to the unaccounted group). + * @param workspaceId - target workspace. + */ + delete(workspaceId: WorkspaceId): Promise + /** + * 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 +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a7e9104b6c..fb4985fef4 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -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 { 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' @@ -109,8 +116,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 } } diff --git a/packages/client/runtime/src/client/sessions/provide.ts b/packages/client/runtime/src/client/sessions/provide.ts new file mode 100644 index 0000000000..8039d7faf7 --- /dev/null +++ b/packages/client/runtime/src/client/sessions/provide.ts @@ -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 + + /** + * @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> = {} + const props: Record = {} + 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 = {} + const props: Record = {} + 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 + } +} diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 19e022d450..64680587e2 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -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 /** 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() - /** 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 = {} - const props: Record = {} - 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> = {} - const props: Record = {} - 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) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 00398d53af..8947e97f7f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -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 { +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). diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 97f01d0bf1..c69661b130 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -6,7 +6,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. */ @@ -30,7 +31,7 @@ export class WorkspaceCreateError 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 /** Workspace baseline and frame owner. */ @@ -43,9 +44,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({ items: [], state: 'idle', phase: 'pending', error: null, @@ -271,7 +272,7 @@ export class WorkspacesService { /** Stable tie-breaking follows Host Workspace order. */ function recentWorkspace( workspaces: readonly WorkspaceView[], - sessions: ReturnType['byId'], + sessions: SessionsPortList['byId'], ): WorkspaceId | undefined { let selected: WorkspaceId | undefined let selectedTime = Number.NEGATIVE_INFINITY diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 22f4a911b3..8f9f96731f 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -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 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 68df3a6da0..46c15d1d05 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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') diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 1b85c52abb..56d398def3 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -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 } } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2ae474be31..2641e0dcc4 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -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 diff --git a/packages/client/ui-conversation/src/client/queue/store.ts b/packages/client/ui-conversation/src/client/queue/store.ts index 5d113b750d..6f084ea39d 100644 --- a/packages/client/ui-conversation/src/client/queue/store.ts +++ b/packages/client/ui-conversation/src/client/queue/store.ts @@ -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 { +export function queueReadFaceOf(session: SessionFace): ObservableSnapshot { return { getSnapshot: () => session.getSnapshot().queue, subscribe: fn => session.subscribe(fn), diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 0d2d8e9d8e..7b119b15ff 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -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 + /** + * Cancel the scoped session's in-flight turn. + * @returns completion; failures reject as in send. + */ + cancel(): Promise + /** + * Pull one older history page for the scoped session. + * @returns completion of the page pull. + */ + loadOlder(): Promise +} /** 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 diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 2dd8aafb4e..bb78596a0b 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -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 } } diff --git a/packages/client/ui-layout/src/client/service.ts b/packages/client/ui-layout/src/client/service.ts index 6b80644855..ecfd862a03 100644 --- a/packages/client/ui-layout/src/client/service.ts +++ b/packages/client/ui-layout/src/client/service.ts @@ -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> +/** + * 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 /** diff --git a/packages/client/ui-slash/src/client/index.ts b/packages/client/ui-slash/src/client/index.ts index 4f192d066e..f1c1d1953e 100644 --- a/packages/client/ui-slash/src/client/index.ts +++ b/packages/client/ui-slash/src/client/index.ts @@ -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 } } diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index c47d44c3d4..c5448a3d50 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -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