Merge origin/master (regenerate the event producer-consumer graph)
This commit is contained in:
56
packages/client/runtime/src/client/contract/session.ts
Normal file
56
packages/client/runtime/src/client/contract/session.ts
Normal 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>
|
||||
47
packages/client/runtime/src/client/contract/sessions-port.ts
Normal file
47
packages/client/runtime/src/client/contract/sessions-port.ts
Normal 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
|
||||
}
|
||||
64
packages/client/runtime/src/client/contract/sessions.ts
Normal file
64
packages/client/runtime/src/client/contract/sessions.ts
Normal 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
|
||||
}
|
||||
65
packages/client/runtime/src/client/contract/workspaces.ts
Normal file
65
packages/client/runtime/src/client/contract/workspaces.ts
Normal 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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
190
packages/client/runtime/src/client/sessions/provide.ts
Normal file
190
packages/client/runtime/src/client/sessions/provide.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<WorkspaceListState>
|
||||
/** 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<WorkspaceListState>({
|
||||
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<SessionsService['list']['getSnapshot']>['byId'],
|
||||
sessions: SessionsPortList['byId'],
|
||||
): WorkspaceId | undefined {
|
||||
let selected: WorkspaceId | undefined
|
||||
let selectedTime = Number.NEGATIVE_INFINITY
|
||||
|
||||
@@ -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[] }))
|
||||
|
||||
Reference in New Issue
Block a user