Merge remote-tracking branch 'github/master' into xtr/trajectory-inspection-ui

# Conflicts:
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
This commit is contained in:
_Kerman
2026-07-29 09:48:24 +08:00
356 changed files with 8801 additions and 1511 deletions

View File

@@ -0,0 +1,63 @@
/**
* 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>
/**
* Exhaust the available history for inspection features.
* An abort stops before the next page without abandoning an active request.
* @param signal - Consumer lifetime; abort is observed between pages.
* @returns completion when history is exhausted or paging stops making progress.
*/
loadAllHistory(signal?: AbortSignal): Promise<void>
}
/**
* The full outward face: behavior verbs plus the conversation read side
* (the `useSession` hook source). This is the type carried by
* `SessionBinding.session` and the provide channel.
*/
export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,17 @@ import type { UseProjection } from './sessions/projection-store.ts'
export { SlotsService } from './slots.ts'
export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
// The provide channel is shared with the client test runtime (one
// materialization/projection implementation; no test-side mirror to drift).
export { SessionProvideChannel } from './sessions/provide.ts'
export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { 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'
@@ -118,8 +125,10 @@ declare module 'cordis' {
}
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
workspaces: import('./workspaces/service.ts').WorkspacesService
/** The outward face only; the concrete service stays inside the runtime. */
sessions: import('./contract/sessions.ts').ISessions
/** The outward face only; the concrete service stays inside the runtime. */
workspaces: import('./contract/workspaces.ts').IWorkspaces
}
}

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import type {
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type { SessionFace } from '../contract/session.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot,
OpenState, PromptError, QueuedMessage, RunningToolCall,
@@ -73,11 +73,13 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
}
/**
* Owns a session's event window and exposes two observable read surfaces:
* the folded chat conversation and the raw history window. React bindings
* remain outside this data layer.
* Owns a session's event window, folded conversation, raw history inspection,
* and observable 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).
@@ -231,12 +233,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.notifier.markDirty()
return result
}
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt has logged
// its user/message on the host (events.length > 0 is fact, not
// optimism), while a rejected first prompt must keep the session blank
// — the client-side blank mirror only ever lowers, so flipping early on
// a failure would surface the session forever and strip its
// connectWorkspace reuse eligibility against the host's authority.
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
// conversation's first turn on the host (the host criterion — a logged
// turn/start — is fact, not optimism; standalone command and projection
// events never flip it), while a rejected first prompt must keep the
// session blank — the client-side blank mirror only ever lowers, so
// flipping early on a failure would surface the session forever and
// strip its connectWorkspace reuse eligibility against the host's
// authority.
if (this.blankBit) {
this.blankBit = false
this.options.onEngaged?.(this)

View File

@@ -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

View File

@@ -153,6 +153,15 @@ export class FakeApiClient implements IApiClient {
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
}
readonly goals: IApiClient['goals'] = {
create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))),
clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */
suppressStreamOpen = false