Merge remote-tracking branch 'origin/master' into codex/basic-session-search
Conflict resolutions: - `session.list`: master's projection columns fold into the PR's cancellable, batched `listVisibleSessionSummaries`, which `session.search` shares as its visibility baseline; master's goal helpers stay beside it. - Client sessions face: master narrowed `ctx.sessions` to `ISessions`, so the search verb and its protocol-constant bound are declared there and the test-runtime double implements them (recorded, empty page unless a scenario stubs hits). - `WorkspaceBrowser`: master's per-row Rename wiring rides the PR's search results view; the tree keeps the PR's query-free derivations. - `dsh web` bin: the PR's shutdown-handlers-before-readiness order with master's boot-time LAN address snapshot. - `session-query-sqlite`: master's `SCHEMA_VERSION` 7 stands; the PR's bump carried no schema change. - Specs: master wraps assistant/steering message payloads and requires an `application/json` carrier request, so the search fixtures and tests follow. - Web aria goldens keep master's recording plus the PR's search placeholder; the navigation-panes inventory keeps master's terminal-card golden next to the PR's search-results golden.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionHistoryInspection } from '../sessions/history.ts'
|
||||
import type { ObservableSnapshot } from './store.ts'
|
||||
|
||||
/** Observable state of one independently loaded session history ledger. */
|
||||
export interface SessionHistorySnapshot {
|
||||
state: 'cold' | 'loading' | 'ready' | 'error'
|
||||
error: RpcError | null
|
||||
hasMore: boolean
|
||||
inspection: SessionHistoryInspection
|
||||
}
|
||||
|
||||
/** Read-only history source addressed by session id. */
|
||||
export interface SessionHistoryFace
|
||||
extends ObservableSnapshot<SessionHistorySnapshot> {
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Load the tail and exhaust every available older page.
|
||||
* @param signal - Consumer lifetime; abort is observed between page requests.
|
||||
* @returns When the available ledger is complete or stops advancing.
|
||||
*/
|
||||
loadAll(signal?: AbortSignal): Promise<void>
|
||||
}
|
||||
|
||||
/** Runtime service resolving independent history sources. */
|
||||
export interface ISessionHistory {
|
||||
/**
|
||||
* Resolve the identity-stable source for a session.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns The source owned outside Session and SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace
|
||||
}
|
||||
70
packages/client/runtime/src/client/contract/session.ts
Normal file
70
packages/client/runtime/src/client/contract/session.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 }>>
|
||||
/**
|
||||
* Rename this session (explicit user title; pins it against automatic
|
||||
* regeneration).
|
||||
* @param title - raw title text (the host normalizes acceptance).
|
||||
* @returns the normalized accepted title and its event seq, or the business error.
|
||||
*/
|
||||
rename(title: string): Promise<RpcResult<{ title: string; seq: number }>>
|
||||
/**
|
||||
* Extend the history window backwards (older messages pagination).
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle).
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
*/
|
||||
command(line: string): Promise<RpcResult<{ matched: boolean }>>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
82
packages/client/runtime/src/client/contract/sessions.ts
Normal file
82
packages/client/runtime/src/client/contract/sessions.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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 { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionSearchResultItem } from '../sessions/manager.ts'
|
||||
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>
|
||||
/**
|
||||
* The `session.search` result bound the wire schema fixes, exposed to
|
||||
* presentation as injected data. Not per-connection state: every transport
|
||||
* (fixture included) reports the same number.
|
||||
*/
|
||||
readonly searchResultLimit: number
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* Search the Host's visible message-content index. Results stay
|
||||
* request-local; the list snapshot remains the metadata authority.
|
||||
* @param query - non-blank literal phrase.
|
||||
* @param signal - cancellation for a superseded search.
|
||||
* @returns bounded results, or a business/transport error.
|
||||
*/
|
||||
search(
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
79
packages/client/runtime/src/client/contract/workspaces.ts
Normal file
79
packages/client/runtime/src/client/contract/workspaces.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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 { DirectoryListing, 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>
|
||||
/**
|
||||
* List one directory level through the Host's `browse` capability.
|
||||
* @param path - absolute directory to list; absent lists the Host home directory.
|
||||
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
|
||||
* @returns the level's listing with breadcrumb ancestry.
|
||||
*/
|
||||
listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
|
||||
/**
|
||||
* Create one child directory through the Host's `browse` capability.
|
||||
* @param path - absolute existing parent directory.
|
||||
* @param name - single non-blank path segment.
|
||||
* @returns the created directory's absolute path.
|
||||
*/
|
||||
createDirectory(path: string, name: string): Promise<string>
|
||||
/**
|
||||
* 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>
|
||||
}
|
||||
@@ -5,35 +5,64 @@ import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-
|
||||
import { SlotsService } from './slots.ts'
|
||||
import { SessionsService } from './sessions/service.ts'
|
||||
import type { SessionListState } from './sessions/service.ts'
|
||||
import { SessionHistoryService } from './session-history/service.ts'
|
||||
import { WorkspacesService } from './workspaces/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
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'
|
||||
export { SessionHistoryService } from './session-history/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 { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
|
||||
export type {
|
||||
ISessionHistory, SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from './contract/session-history.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'
|
||||
export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
|
||||
export type { WorkspaceListPhase } from './workspaces/manager.ts'
|
||||
export type { WorkspaceListState } from './workspaces/service.ts'
|
||||
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
export type {
|
||||
DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Runtime owns the snapshot store; web-react only binds it to React.
|
||||
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
|
||||
export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
} from './contract/store.ts'
|
||||
export type {
|
||||
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
|
||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
export type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from './sessions/conversation-context.ts'
|
||||
export type {
|
||||
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
|
||||
} from './sessions/request-inspection.ts'
|
||||
export type { ConversationHistoryProjection } from './session-history/history-fold.ts'
|
||||
export type { SessionHistoryInspection } from './sessions/history.ts'
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
// Projection value store (session-projection RFC, push model): host-computed
|
||||
// whole values per key; domains ship projection support with zero client code.
|
||||
export type {
|
||||
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
|
||||
} from './sessions/projection-store.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
@@ -59,12 +88,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
/** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */
|
||||
useProjection: UseProjection
|
||||
}
|
||||
/** Standard kit for slots that remain mounted while current session changes. */
|
||||
interface SessionMaybeStandardProps {
|
||||
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
|
||||
/** Current session id; absent in the no-session state. */
|
||||
sessionId: SessionId | undefined
|
||||
/** Key-addressed projection reader; every key reads absent while no session is current. */
|
||||
useProjection: UseProjection
|
||||
}
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
@@ -99,8 +132,12 @@ 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
|
||||
/** Read-only history sources isolated from Chat sessions and workspace state. */
|
||||
sessionHistory: import('./contract/session-history.ts').ISessionHistory
|
||||
/** The outward face only; the concrete service stays inside the runtime. */
|
||||
workspaces: import('./contract/workspaces.ts').IWorkspaces
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,24 +151,55 @@ export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const sessions = new SessionsService(ctx, connection.api)
|
||||
const sessionHistory = new SessionHistoryService(ctx, connection.api)
|
||||
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
|
||||
ctx.effect(
|
||||
() => workspaces.startInitialSelection(),
|
||||
'runtime: initial Workspace selection',
|
||||
)
|
||||
const loop = connection.start({
|
||||
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
|
||||
onMuxEnvelope: (envelope) => {
|
||||
sessions.handleMuxEnvelope(envelope)
|
||||
try {
|
||||
sessionHistory.handleMuxEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onHostEnvelope: (envelope) => {
|
||||
sessions.handleHostEnvelope(envelope)
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches) subscribe on ctx.
|
||||
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
try {
|
||||
sessionHistory.handleHostEnvelope(envelope)
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history host-frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
ctx.emit('connection/reset')
|
||||
try {
|
||||
sessionHistory.handleConnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history reconnect failed:', error)
|
||||
}
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
// Generation death fires before any next-generation frame can arrive
|
||||
// (reconnect replays flow from stream open, ahead of onConnected):
|
||||
// the only safe moment to drop generation-scoped interaction state.
|
||||
if (state === 'reconnecting') {
|
||||
sessions.handleDisconnected()
|
||||
try {
|
||||
sessionHistory.handleDisconnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history disconnect failed:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
HistoryEntry, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '../sessions/conversation.ts'
|
||||
import { toAssistantBlocks } from '../sessions/conversation.ts'
|
||||
import type {
|
||||
ConversationContext, ConversationContextOriginKind,
|
||||
} from '../sessions/conversation-context.ts'
|
||||
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
argsRaw: string
|
||||
time: number
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
interface FoldedContext {
|
||||
generation: number
|
||||
nodes: readonly number[]
|
||||
originSeq?: number
|
||||
}
|
||||
|
||||
interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/** Immutable conversation projections derived only from the history source. */
|
||||
export interface ConversationHistoryProjection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
// Trajectory owns surface-window reconstruction so its immutable ledger does
|
||||
// not depend on Chat's live fold adapter or Session's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind {
|
||||
if (event?.type !== 'user/message') return 'rewrite'
|
||||
const source = event.data.source
|
||||
if (typeof source === 'object' && 'kind' in source && 'plugin' in source) {
|
||||
if (source.plugin === 'compact') return 'compaction'
|
||||
if (source.plugin === 'rewind') return 'rewind'
|
||||
}
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const surface = new SurfaceManager(replay)
|
||||
const contexts: FoldedContext[] = []
|
||||
let generation = 0
|
||||
let originSeq: number | undefined
|
||||
for (const event of events) {
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
generation++
|
||||
originSeq = event.seq
|
||||
}
|
||||
replay.push(event)
|
||||
}
|
||||
contexts.push({
|
||||
generation,
|
||||
nodes: [...surface.nodes],
|
||||
...(originSeq === undefined ? {} : { originSeq }),
|
||||
})
|
||||
return contexts
|
||||
}
|
||||
|
||||
// History projection owns its node mapping so Chat's live adapter remains free
|
||||
// of inspection metadata and lifecycle coupling.
|
||||
/* jscpd:ignore-start */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
assistantTiming: AssistantTiming | undefined,
|
||||
requestConfig: AssistantRequestConfig | undefined,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
provenance: {
|
||||
provider: event.data.message.source.provider,
|
||||
model: event.data.message.source.model,
|
||||
},
|
||||
...(requestConfig === undefined ? {} : { requestConfig }),
|
||||
...(assistantTiming === undefined ? {} : { timing: assistantTiming }),
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
const call = callIndex.get(callId)
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId,
|
||||
call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call?.time ?? null,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
resultView,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
function projectTransient(entries: readonly HistoryEntry[]): Pick<
|
||||
ConversationHistoryProjection,
|
||||
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
|
||||
> {
|
||||
let partial: PartialAccumulator | null = null
|
||||
const openCalls = new Map<string, RunningToolCall>()
|
||||
const interruptedNodes: ConversationNode[] = []
|
||||
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const { event } = entry
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
// The independent replay emits the same public running-call shape as
|
||||
// Chat without reading or mutating Session's live index.
|
||||
/* jscpd:ignore-start */
|
||||
codeDispatches.set(data.parentCallId, [...siblings, {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0,
|
||||
step: 0,
|
||||
time: event.time,
|
||||
callView: null,
|
||||
}])
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const siblings = codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
// History independently reproduces the public settled-call shape instead
|
||||
// of consuming Session's live code-dispatch projection.
|
||||
/* jscpd:ignore-start */
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: started?.time ?? null,
|
||||
content: data.content,
|
||||
isError: data.isError,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
}
|
||||
codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1
|
||||
? [...siblings, settled]
|
||||
: siblings.map((sub, index) => index === at ? settled : sub),
|
||||
)
|
||||
/* jscpd:ignore-end */
|
||||
continue
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'assistant/chunk': {
|
||||
const { turn, step, chunk } = event.data
|
||||
if (partial === null || partial.turn !== turn || partial.step !== step) {
|
||||
partial = new PartialAccumulator(turn, step)
|
||||
}
|
||||
partial.push(chunk)
|
||||
break
|
||||
}
|
||||
case 'assistant/message':
|
||||
if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null
|
||||
break
|
||||
case 'tool/call':
|
||||
// History reconstructs its own in-flight index; this intentionally
|
||||
// mirrors the published Chat node shape, not Chat's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
openCalls.set(String(event.data.callId), {
|
||||
callId: String(event.data.callId),
|
||||
name: event.data.name,
|
||||
argsRaw: event.data.arguments,
|
||||
turn: event.data.turn,
|
||||
step: event.data.step,
|
||||
time: event.time,
|
||||
callView: entry.view?.for === 'call' ? entry.view.view : null,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
break
|
||||
case 'tool/result':
|
||||
openCalls.delete(String(event.data.message.source.callId))
|
||||
break
|
||||
case 'turn/end': {
|
||||
if (partial !== null && partial.turn === event.data.turn) {
|
||||
const { blocks } = partial.toPartial()
|
||||
const visible = blocks.some(block =>
|
||||
block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true)
|
||||
if (visible) {
|
||||
interruptedNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: partial.turn, step: partial.step, blocks, interrupted: true,
|
||||
})
|
||||
}
|
||||
partial = null
|
||||
}
|
||||
let callOffset = 0
|
||||
for (const [callId, call] of openCalls) {
|
||||
if (call.turn !== event.data.turn) continue
|
||||
openCalls.delete(callId)
|
||||
// Interrupted terminal nodes are reconstructed independently so a
|
||||
// Trajectory replay cannot observe Session's frozen-node lifecycle.
|
||||
/* jscpd:ignore-start */
|
||||
interruptedNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01,
|
||||
time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call.time,
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView,
|
||||
resultView: null,
|
||||
})
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interruptedNodes,
|
||||
partial: partial?.toPartial() ?? null,
|
||||
runningCalls: [...openCalls.values()],
|
||||
codeDispatches,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one immutable history ledger without reading or mutating Chat state.
|
||||
* @param entries - Contiguous history entries in sequence order.
|
||||
* @returns Event order, context lineage, and transient tail state.
|
||||
*/
|
||||
export function projectConversationHistory(
|
||||
entries: readonly HistoryEntry[],
|
||||
): ConversationHistoryProjection {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const baseSeq = events[0]?.seq ?? 0
|
||||
const padded = [
|
||||
...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)),
|
||||
...events,
|
||||
]
|
||||
const callIndex = new Map<string, CallIndexEntry>()
|
||||
const resultViews = new Map<number, ToolResultView>()
|
||||
const assistantSteps = new Map<string, AssistantStepMetadata>()
|
||||
const assistantTimings = new Map<number, AssistantTiming>()
|
||||
const assistantRequestConfigs = new Map<number, AssistantRequestConfig>()
|
||||
const promptsByContext = new Map<number, ConversationPromptSnapshot>()
|
||||
let activeRequestConfig: AssistantRequestConfig | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let contextGeneration = 0
|
||||
|
||||
for (const [index, event] of events.entries()) {
|
||||
const view = entries[index]?.view
|
||||
if (event.type === 'tool/call') {
|
||||
callIndex.set(String(event.data.callId), {
|
||||
name: event.data.name,
|
||||
argsRaw: event.data.arguments,
|
||||
time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
} else if (event.type === 'tool/result' && view?.for === 'result') {
|
||||
resultViews.set(event.seq, view.view)
|
||||
}
|
||||
if (isSurfaceEvent(event) && event.surfaceOp !== 'append') {
|
||||
contextGeneration++
|
||||
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
|
||||
}
|
||||
if (event.type === 'request/header') {
|
||||
activeRequestConfig = event.data.header.config
|
||||
activePrompt = {
|
||||
config: event.data.header.config,
|
||||
system: event.data.header.system ?? '',
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
promptsByContext.set(contextGeneration, activePrompt)
|
||||
} else if (event.type === 'step/start') {
|
||||
assistantSteps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = assistantSteps.get(key) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}
|
||||
if (current.firstTokenTime === null) {
|
||||
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
} else if (event.type === 'assistant/message') {
|
||||
assistantTimings.set(
|
||||
event.seq,
|
||||
{
|
||||
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}),
|
||||
completedTime: event.time,
|
||||
},
|
||||
)
|
||||
if (activeRequestConfig !== undefined) {
|
||||
assistantRequestConfigs.set(event.seq, activeRequestConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodeCache = new Map<number, ConversationNode>()
|
||||
const materialize = (seq: number): ConversationNode | undefined => {
|
||||
const cached = nodeCache.get(seq)
|
||||
if (cached !== undefined) return cached
|
||||
const event = padded[seq]
|
||||
if (event === undefined || !isSurfaceEligibleType(event.type)) return
|
||||
const node = materializeNode(
|
||||
event,
|
||||
callIndex,
|
||||
resultViews.get(seq) ?? null,
|
||||
assistantTimings.get(seq),
|
||||
assistantRequestConfigs.get(seq),
|
||||
)
|
||||
nodeCache.set(seq, node)
|
||||
return node
|
||||
}
|
||||
const eventNodes = events.flatMap((event) => {
|
||||
const node = materialize(event.seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
|
||||
let contexts: readonly ConversationContext[]
|
||||
if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) {
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
} else {
|
||||
try {
|
||||
contexts = foldContexts(padded).map((context): ConversationContext => {
|
||||
const nodes = context.nodes.flatMap((seq) => {
|
||||
const node = materialize(seq)
|
||||
return node === undefined ? [] : [node]
|
||||
})
|
||||
const prompt = promptsByContext.get(context.generation)
|
||||
if (context.originSeq === undefined) {
|
||||
return {
|
||||
id: context.generation,
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
}
|
||||
const originEvent = padded[context.originSeq]
|
||||
return {
|
||||
id: context.generation,
|
||||
parentId: context.generation - 1,
|
||||
origin: contextOriginKind(originEvent),
|
||||
originSeq: context.originSeq,
|
||||
...(originEvent === undefined ? {} : { createdAt: originEvent.time }),
|
||||
...(prompt === undefined ? {} : { prompt }),
|
||||
nodes,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history surface fold failed, using event order:', error)
|
||||
contexts = [{
|
||||
id: 0,
|
||||
...(activePrompt === undefined ? {} : { prompt: activePrompt }),
|
||||
nodes: eventNodes,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventNodes,
|
||||
contexts,
|
||||
...projectTransient(entries),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
ISessionHistory, SessionHistoryFace,
|
||||
} from '../contract/session-history.ts'
|
||||
import { SessionHistorySource } from './source.ts'
|
||||
|
||||
/** Root registry and frame router for independent inspection histories. */
|
||||
export class SessionHistoryService implements ISessionHistory {
|
||||
private readonly sources = new Map<SessionId, SessionHistorySource>()
|
||||
|
||||
/**
|
||||
* @param ctx - Client root context.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly api: IApiClient) {
|
||||
ctx.reflect.provide('sessionHistory', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one identity-stable history source.
|
||||
* @param sessionId - Host session identity.
|
||||
* @returns Source independent from SessionManager.
|
||||
*/
|
||||
source(sessionId: SessionId): SessionHistoryFace {
|
||||
let source = this.sources.get(sessionId)
|
||||
if (source === undefined) {
|
||||
source = new SessionHistorySource(sessionId, this.api)
|
||||
this.sources.set(sessionId, source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Route history-relevant mux frames only to an existing source.
|
||||
* @param envelope - Validated mux envelope.
|
||||
*/
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return
|
||||
this.sources.get(frame.sessionId)?.handleMuxFrame(frame)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a removed session's independent history source.
|
||||
* @param envelope - Validated host envelope.
|
||||
*/
|
||||
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type !== 'host/session-removed') return
|
||||
this.sources.get(frame.sessionId)?.dispose()
|
||||
this.sources.delete(frame.sessionId)
|
||||
}
|
||||
|
||||
/** Invalidate requests from the dead connection generation. */
|
||||
handleDisconnected(): void {
|
||||
for (const source of this.sources.values()) source.handleDisconnected()
|
||||
}
|
||||
|
||||
/** Rebuild every previously activated source from the new generation. */
|
||||
handleConnected(): void {
|
||||
for (const source of this.sources.values()) source.resync()
|
||||
}
|
||||
}
|
||||
352
packages/client/runtime/src/client/session-history/source.ts
Normal file
352
packages/client/runtime/src/client/session-history/source.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
SessionHistoryFace, SessionHistorySnapshot,
|
||||
} from '../contract/session-history.ts'
|
||||
import { createHistoryInspection } from '../sessions/history.ts'
|
||||
import { Notifier } from '../sessions/notifier.ts'
|
||||
|
||||
const HISTORY_PAGE_MESSAGES = 50
|
||||
|
||||
function isAborted(signal: AbortSignal | undefined): boolean {
|
||||
return signal?.aborted === true
|
||||
}
|
||||
|
||||
/** Independent raw-history owner used only by inspection consumers. */
|
||||
export class SessionHistorySource implements SessionHistoryFace {
|
||||
private entries: readonly HistoryEntry[] = []
|
||||
private baseSeq = 0
|
||||
private hasMore = false
|
||||
private state: SessionHistorySnapshot['state'] = 'cold'
|
||||
private error: RpcError | null = null
|
||||
private generation = 0
|
||||
private persistentConsumer = false
|
||||
private readonly consumerSignals = new Set<AbortSignal>()
|
||||
private openPromise: Promise<void> | null = null
|
||||
private olderPromise: Promise<void> | null = null
|
||||
private stitching = false
|
||||
private liveBuffer: HistoryEntry[] = []
|
||||
private subscribedLastSeq: number | null = null
|
||||
private inspectionCache: {
|
||||
entries: readonly HistoryEntry[]
|
||||
value: SessionHistorySnapshot['inspection']
|
||||
} | null = null
|
||||
private snapshotCache: SessionHistorySnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
})
|
||||
|
||||
/**
|
||||
* @param sessionId - Host session identity.
|
||||
* @param api - Shared wire client.
|
||||
*/
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
private readonly api: IApiClient,
|
||||
) {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to ledger changes.
|
||||
* @param listener - Change callback.
|
||||
* @returns Unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
return this.notifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached ledger snapshot.
|
||||
* @returns Stable snapshot until the source changes.
|
||||
*/
|
||||
getSnapshot(): SessionHistorySnapshot {
|
||||
this.notifier.ensureFresh()
|
||||
return this.snapshotCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the tail and exhaust all available older pages.
|
||||
* @param signal - Consumer lifetime.
|
||||
* @returns When paging completes, fails to advance, or is aborted.
|
||||
*/
|
||||
async loadAll(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted === true) return
|
||||
this.trackConsumer(signal)
|
||||
await this.open()
|
||||
while (
|
||||
!isAborted(signal)
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
|
||||
private async loadForConsumers(): Promise<void> {
|
||||
await this.open()
|
||||
while (
|
||||
this.hasConsumer()
|
||||
&& this.state === 'ready'
|
||||
&& this.hasMore
|
||||
) {
|
||||
const previousBaseSeq = this.baseSeq
|
||||
await this.loadOlder()
|
||||
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a relevant mux frame without involving the Chat session.
|
||||
* @param frame - Session-addressed frame.
|
||||
*/
|
||||
handleMuxFrame(frame: MuxFrame): void {
|
||||
if (frame.type === 'session/subscribed') {
|
||||
this.subscribedLastSeq = frame.lastSeq
|
||||
return
|
||||
}
|
||||
if (frame.type !== 'session/event') return
|
||||
this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) })
|
||||
}
|
||||
|
||||
/** Invalidate dead-generation requests while retaining the last readable snapshot. */
|
||||
handleDisconnected(): void {
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
if (this.state !== 'cold') {
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuild an activated ledger from the new connection generation. */
|
||||
resync(): void {
|
||||
if (!this.hasConsumer()) return
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.stitching = false
|
||||
this.liveBuffer = []
|
||||
this.subscribedLastSeq = null
|
||||
this.entries = []
|
||||
this.baseSeq = 0
|
||||
this.hasMore = false
|
||||
this.state = 'cold'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
void this.loadForConsumers()
|
||||
}
|
||||
|
||||
/** Stop future refresh work after the host removes the session. */
|
||||
dispose(): void {
|
||||
this.persistentConsumer = false
|
||||
this.consumerSignals.clear()
|
||||
this.generation++
|
||||
this.openPromise = null
|
||||
this.olderPromise = null
|
||||
this.liveBuffer = []
|
||||
}
|
||||
|
||||
private open(): Promise<void> {
|
||||
if (this.state === 'ready') return Promise.resolve()
|
||||
if (this.openPromise !== null) return this.openPromise
|
||||
const generation = this.generation
|
||||
const operation = this.doOpen(generation)
|
||||
const settled = operation.finally(() => {
|
||||
if (this.openPromise === settled) this.openPromise = null
|
||||
})
|
||||
this.openPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private trackConsumer(signal: AbortSignal | undefined): void {
|
||||
if (signal === undefined) {
|
||||
this.persistentConsumer = true
|
||||
return
|
||||
}
|
||||
if (this.consumerSignals.has(signal)) return
|
||||
this.consumerSignals.add(signal)
|
||||
signal.addEventListener('abort', () => {
|
||||
this.consumerSignals.delete(signal)
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
private hasConsumer(): boolean {
|
||||
return this.persistentConsumer || this.consumerSignals.size > 0
|
||||
}
|
||||
|
||||
private async doOpen(generation: number): Promise<void> {
|
||||
this.state = 'loading'
|
||||
this.error = null
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
let { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation) return
|
||||
if (!result.ok) {
|
||||
this.state = 'error'
|
||||
this.error = result.error
|
||||
return
|
||||
}
|
||||
this.installTail(result.value.events, result.value.hasMore, true)
|
||||
const tailSeq = this.tailSeq()
|
||||
if (
|
||||
this.subscribedLastSeq !== null
|
||||
&& tailSeq !== null
|
||||
&& this.subscribedLastSeq > tailSeq
|
||||
) {
|
||||
result = (await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})).result
|
||||
if (generation !== this.generation) return
|
||||
if (result.ok) this.installTail(result.value.events, result.value.hasMore, true)
|
||||
}
|
||||
this.state = 'ready'
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
this.state = 'error'
|
||||
const folded = transportError<never>(error)
|
||||
/* v8 ignore next -- transportError always returns the error branch. */
|
||||
this.error = folded.ok ? null : folded.error
|
||||
} finally {
|
||||
if (generation === this.generation) this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private loadOlder(): Promise<void> {
|
||||
if (this.olderPromise !== null) return this.olderPromise
|
||||
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
|
||||
const generation = this.generation
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
beforeSeq: this.baseSeq,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (generation !== this.generation || this.state !== 'ready' || !result.ok) return
|
||||
const older = result.value.events
|
||||
if (older.length === 0) {
|
||||
this.hasMore = result.value.hasMore
|
||||
return
|
||||
}
|
||||
const tail = older.at(-1)
|
||||
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
|
||||
console.error(
|
||||
`[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`,
|
||||
)
|
||||
this.hasMore = false
|
||||
return
|
||||
}
|
||||
this.entries = [...older, ...this.entries]
|
||||
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
|
||||
this.hasMore = result.value.hasMore
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history paging failed:', error)
|
||||
}
|
||||
})()
|
||||
const settled = operation.finally(() => {
|
||||
if (this.olderPromise !== settled) return
|
||||
this.olderPromise = null
|
||||
this.notifier.markDirty()
|
||||
})
|
||||
this.olderPromise = settled
|
||||
return settled
|
||||
}
|
||||
|
||||
private installTail(
|
||||
tail: readonly HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
replace: boolean,
|
||||
): void {
|
||||
if (replace) {
|
||||
this.entries = [...tail]
|
||||
this.hasMore = hasMore
|
||||
} else {
|
||||
const firstSeq = tail[0]?.event.seq
|
||||
const prefix = firstSeq === undefined
|
||||
? this.entries
|
||||
: this.entries.filter(entry => entry.event.seq < firstSeq)
|
||||
this.entries = [...prefix, ...tail]
|
||||
}
|
||||
this.baseSeq = this.entries[0]?.event.seq ?? 0
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const entry of buffered) this.appendLive(entry)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private acceptLive(entry: HistoryEntry): void {
|
||||
if (this.state === 'loading' || this.stitching) {
|
||||
this.liveBuffer.push(entry)
|
||||
return
|
||||
}
|
||||
if (this.state !== 'ready') return
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq > tailSeq + 1) {
|
||||
this.liveBuffer.push(entry)
|
||||
void this.repairGap()
|
||||
return
|
||||
}
|
||||
this.appendLive(entry)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private appendLive(entry: HistoryEntry): void {
|
||||
const tailSeq = this.tailSeq()
|
||||
if (tailSeq !== null && entry.event.seq <= tailSeq) return
|
||||
this.entries = [...this.entries, entry]
|
||||
}
|
||||
|
||||
private async repairGap(): Promise<void> {
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.generation
|
||||
try {
|
||||
const { result } = await this.api.sessions.history({
|
||||
sessionId: this.sessionId,
|
||||
maxMessages: HISTORY_PAGE_MESSAGES,
|
||||
})
|
||||
if (result.ok && generation === this.generation && this.state === 'ready') {
|
||||
this.installTail(result.value.events, result.value.hasMore, false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] inspection history gap repair failed:', error)
|
||||
} finally {
|
||||
if (generation === this.generation) this.stitching = false
|
||||
}
|
||||
}
|
||||
|
||||
private tailSeq(): number | null {
|
||||
return this.entries.at(-1)?.event.seq ?? null
|
||||
}
|
||||
|
||||
private buildSnapshot(): SessionHistorySnapshot {
|
||||
if (this.inspectionCache?.entries !== this.entries) {
|
||||
const entries = this.entries
|
||||
this.inspectionCache = {
|
||||
entries,
|
||||
value: createHistoryInspection(() => entries),
|
||||
}
|
||||
}
|
||||
return {
|
||||
state: this.state,
|
||||
error: this.error,
|
||||
hasMore: this.hasMore,
|
||||
inspection: this.inspectionCache.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { ConversationPromptSnapshot } from './request-inspection.ts'
|
||||
|
||||
/** Operation that started a new append-only model context. */
|
||||
export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite'
|
||||
|
||||
/** One immutable model-context generation reconstructed from surface replacements. */
|
||||
export interface ConversationContext {
|
||||
/** Zero-based generation within the session; stable across later appends. */
|
||||
id: number
|
||||
/** Previous generation in this session; absent for the initial context. */
|
||||
parentId?: number
|
||||
/** Why this generation exists; absent for the initial context. */
|
||||
origin?: ConversationContextOriginKind
|
||||
/** Event seq of the replacement that created this generation. */
|
||||
originSeq?: number
|
||||
/** Unix epoch ms of the replacement that created this generation. */
|
||||
createdAt?: number
|
||||
/** Latest request header observed in this generation, inherited until a later header replaces it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Final frozen nodes for historical generations, or current folded nodes for the tail. */
|
||||
nodes: readonly ConversationNode[]
|
||||
}
|
||||
@@ -3,15 +3,33 @@
|
||||
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
|
||||
// string here (narrow to real brands when convenient).
|
||||
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
RpcError, SessionId, ToolCallView, ToolResultView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { PendingInteraction } from './pending.ts'
|
||||
|
||||
export type { TodoItem }
|
||||
|
||||
/** Request configuration recorded for one provider call. */
|
||||
export interface AssistantRequestConfig {
|
||||
provider: string
|
||||
model: string
|
||||
purpose?: string
|
||||
thinking?: string
|
||||
reasoningEffort?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
stop?: readonly string[]
|
||||
}
|
||||
|
||||
/** Stable provider/model identity reported for one completed request. */
|
||||
export interface AssistantProvenanceView {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Assistant content blocks sorted by what the UI cares about
|
||||
* (text body / collapsible reasoning / tool-call card head / other fallback). */
|
||||
export type AssistantBlock =
|
||||
@@ -53,6 +71,16 @@ export interface UserMessageNode {
|
||||
source: unknown
|
||||
}
|
||||
|
||||
/** Recorded boundaries used to derive assistant latency and throughput. */
|
||||
export interface AssistantTiming {
|
||||
/** Matching step/start timestamp, or null when it is outside the current event window. */
|
||||
stepStartTime: number | null
|
||||
/** First non-empty text/reasoning/tool delta timestamp, or null when no token delta was recorded. */
|
||||
firstTokenTime: number | null
|
||||
/** Final assistant/message timestamp. */
|
||||
completedTime: number
|
||||
}
|
||||
|
||||
/** A finalized (or interruption-frozen) assistant message. */
|
||||
export interface AssistantMessageNode {
|
||||
kind: 'assistant'
|
||||
@@ -63,6 +91,10 @@ export interface AssistantMessageNode {
|
||||
step: number
|
||||
blocks: readonly AssistantBlock[]
|
||||
usage?: unknown
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
/** Timing derived from the recorded step/chunk/message event sequence. */
|
||||
timing?: AssistantTiming
|
||||
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
|
||||
* Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */
|
||||
interrupted?: true
|
||||
@@ -120,6 +152,31 @@ export interface UnknownSurfaceNode {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* One slash-command lifecycle folded from the log-only `command/run` /
|
||||
* `command/done` pair (paired by commandId, mirroring tool call↔result).
|
||||
* Log-only events never enter the surface fold, so the FoldAdapter indexes
|
||||
* them separately and merges the nodes into the flow by seq. A window cut
|
||||
* between the pair soft-falls like tool pairs: a done with no in-window run
|
||||
* still builds a node (name/args null), and a run with no done renders as
|
||||
* still executing.
|
||||
*/
|
||||
export interface CommandNode {
|
||||
kind: 'command'
|
||||
/** Seq of the command/run event; the done event's seq when only the done is in-window. */
|
||||
seq: number
|
||||
/** Unix epoch ms of the anchoring event. */
|
||||
time: number
|
||||
/** Pairing id minted by the host executor. */
|
||||
commandId: CommandId
|
||||
/** Command name (run payload's structured field); null when the run fell outside the window. */
|
||||
name: string | null
|
||||
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
|
||||
args: string | null
|
||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
export type ConversationNode =
|
||||
| UserMessageNode
|
||||
@@ -127,6 +184,7 @@ export type ConversationNode =
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| ToolResultNode
|
||||
| CommandNode
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
@@ -243,7 +301,4 @@ export interface ConversationSnapshot {
|
||||
*/
|
||||
blank: boolean
|
||||
lastAgentError: string | null
|
||||
/** Current whole-list `todo/write` projection — the tail page's full-log value, then each live
|
||||
* write (last write wins); empty = the log holds no plan. */
|
||||
todos: readonly TodoItem[]
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
// Subpath export (package.json exports "./surface", alias added for this): all value imports
|
||||
// go through it — the package root points at lib/index.js (needs a build) which the vite
|
||||
// browser bundle cannot resolve; surface.ts has no Node dependencies.
|
||||
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import {
|
||||
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session/surface'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ConversationNode } from './conversation.ts'
|
||||
import type { CommandNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
|
||||
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
|
||||
@@ -32,6 +35,11 @@ function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
|
||||
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
|
||||
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
|
||||
}
|
||||
|
||||
/** One event -> UI node (pure function; the six-variant ConversationNode union). */
|
||||
function materializeNode(
|
||||
event: SessionEvent,
|
||||
@@ -56,21 +64,23 @@ function materializeNode(
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.content, source: event.data.source,
|
||||
content: event.data.message.content, source: event.data.message.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const call = callIndex.get(String(event.data.callId))
|
||||
const result = event.data.message.content[0]
|
||||
const callId = String(event.data.message.source.callId)
|
||||
const call = callIndex.get(callId)
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: String(event.data.callId),
|
||||
callId,
|
||||
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
|
||||
callTime: call?.time ?? null,
|
||||
content: event.data.content, isError: event.data.isError,
|
||||
content: result.content, isError: result.isError === true,
|
||||
...(event.data.error !== undefined ? { error: event.data.error } : {}),
|
||||
meta: event.data.meta,
|
||||
callView: call?.callView ?? null,
|
||||
@@ -99,6 +109,15 @@ export class FoldAdapter {
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/**
|
||||
* Command lifecycle nodes by commandId (insertion = run order). The
|
||||
* `command/run`/`command/done` pair is log-only, so the surface fold never
|
||||
* emits it; this index folds the pair (done settles its run's node in
|
||||
* place) and nodes() merges the products into the flow by seq. Window cuts
|
||||
* soft-fall like tool pairs: a done with no in-window run still builds a
|
||||
* node.
|
||||
*/
|
||||
private commandIdx = new Map<string, CommandNode>()
|
||||
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
|
||||
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
|
||||
* reference-stability contract (§A.9.4) starts here. */
|
||||
@@ -125,13 +144,17 @@ export class FoldAdapter {
|
||||
for (const event of events) this.padded.push(event)
|
||||
this.surface = new SurfaceManager(this.padded)
|
||||
this.nodeCache.clear()
|
||||
this.degraded = false
|
||||
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (event !== undefined) this.indexCall(event, views?.[i])
|
||||
if (event !== undefined) {
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +167,9 @@ export class FoldAdapter {
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.rev++
|
||||
this.padded.push(event)
|
||||
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
|
||||
this.indexCall(event, view)
|
||||
this.indexCommand(event)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +205,23 @@ export class FoldAdapter {
|
||||
this.nodeCache.set(seq, node)
|
||||
out.push(node)
|
||||
}
|
||||
const value = { nodes: out, degraded: this.degraded }
|
||||
// Command nodes fold outside the surface (log-only events); merge by seq.
|
||||
// Both inputs are seq-ascending (surface order and run-index insertion
|
||||
// order share the log order), so one linear merge keeps flow order.
|
||||
let nodes = out
|
||||
if (this.commandIdx.size > 0) {
|
||||
nodes = []
|
||||
const commands = [...this.commandIdx.values()]
|
||||
let next = 0
|
||||
for (const node of out) {
|
||||
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
|
||||
nodes.push(cmd)
|
||||
}
|
||||
nodes.push(node)
|
||||
}
|
||||
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
|
||||
}
|
||||
const value = { nodes, degraded: this.degraded }
|
||||
this.nodesResult = { rev: this.rev, value }
|
||||
return value
|
||||
}
|
||||
@@ -195,6 +236,36 @@ export class FoldAdapter {
|
||||
return seqs
|
||||
}
|
||||
|
||||
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
|
||||
private indexCommand(event: SessionEvent): void {
|
||||
// Log-only plugin events: the host-side dsh-commands declaration cannot
|
||||
// enter the client program, so this wire consumer narrows structurally
|
||||
// (the same posture as tool/code-dispatch in session.ts).
|
||||
if ((event.type as string) === 'command/run') {
|
||||
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
|
||||
this.commandIdx.set(data.commandId, {
|
||||
kind: 'command', seq: event.seq, time: event.time,
|
||||
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
if ((event.type as string) !== 'command/done') return
|
||||
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
|
||||
const run = this.commandIdx.get(data.commandId)
|
||||
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
|
||||
if (run === undefined) {
|
||||
// Cross-window cut: the run page fell out of the window — build the
|
||||
// node from the done alone (same soft-fall as a call-less tool result).
|
||||
this.commandIdx.set(data.commandId, {
|
||||
kind: 'command', seq: event.seq, time: event.time,
|
||||
commandId: data.commandId, name: null, args: null, outcome,
|
||||
})
|
||||
return
|
||||
}
|
||||
// Settle in place: a fresh node object (published references stay immutable).
|
||||
this.commandIdx.set(data.commandId, { ...run, outcome })
|
||||
}
|
||||
|
||||
private indexCall(event: SessionEvent, view?: ToolEventView): void {
|
||||
if (event.type === 'tool/result') {
|
||||
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
||||
|
||||
66
packages/client/runtime/src/client/sessions/history.ts
Normal file
66
packages/client/runtime/src/client/sessions/history.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
|
||||
} from './conversation.ts'
|
||||
import type { ConversationContext } from './conversation-context.ts'
|
||||
import { projectConversationHistory } from '../session-history/history-fold.ts'
|
||||
import { inspectRequests, type RequestView } from './request-inspection.ts'
|
||||
|
||||
/** Lazily derived inspection data for one immutable session-history window. */
|
||||
export interface SessionHistoryInspection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
contexts: readonly ConversationContext[]
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
interruptedNodes: readonly ConversationNode[]
|
||||
partial: PartialAssistant | null
|
||||
runningCalls: readonly RunningToolCall[]
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lazy inspection projection over an immutable history window.
|
||||
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
|
||||
* the entries and replays event order and request lifecycle state.
|
||||
* @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
|
||||
* @returns Lazy, memoized inspection fields for that exact window.
|
||||
*/
|
||||
export function createHistoryInspection(
|
||||
loadEntries: () => readonly HistoryEntry[],
|
||||
): SessionHistoryInspection {
|
||||
let entries: readonly HistoryEntry[] | undefined
|
||||
let conversation: ReturnType<typeof projectConversationHistory> | undefined
|
||||
let requests: ReturnType<typeof inspectRequests> | undefined
|
||||
const historyEntries = () => entries ??= loadEntries()
|
||||
const conversationProjection = () =>
|
||||
conversation ??= projectConversationHistory(historyEntries())
|
||||
const requestProjection = () =>
|
||||
requests ??= inspectRequests(historyEntries())
|
||||
return {
|
||||
get eventNodes() {
|
||||
return conversationProjection().eventNodes
|
||||
},
|
||||
get contexts() {
|
||||
return conversationProjection().contexts
|
||||
},
|
||||
get interruptedNodes() {
|
||||
return conversationProjection().interruptedNodes
|
||||
},
|
||||
get partial() {
|
||||
return conversationProjection().partial
|
||||
},
|
||||
get runningCalls() {
|
||||
return conversationProjection().runningCalls
|
||||
},
|
||||
get codeDispatches() {
|
||||
return conversationProjection().codeDispatches
|
||||
},
|
||||
get requests() {
|
||||
return requestProjection().requests
|
||||
},
|
||||
get callSchemas() {
|
||||
return requestProjection().callSchemas
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary {
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth). */
|
||||
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
|
||||
export interface SessionListEntry {
|
||||
sessionId: SessionId
|
||||
title?: string
|
||||
@@ -19,6 +19,8 @@ export interface SessionListEntry {
|
||||
blank: boolean
|
||||
parentSessionId?: SessionId
|
||||
cwd?: string
|
||||
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
|
||||
waitingApproval: boolean
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
depth: number
|
||||
}
|
||||
@@ -28,9 +30,10 @@ export interface SessionListEntry {
|
||||
* follows the established input order; this projection never re-sorts a
|
||||
* hydrated list from mutable timestamps.
|
||||
* @param summaries - the host's session.list items.
|
||||
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
|
||||
* @returns display rows in render order.
|
||||
*/
|
||||
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
|
||||
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
|
||||
const byId = new Map<SessionId, TitledSessionSummary>()
|
||||
for (const s of summaries) byId.set(s.sessionId, s)
|
||||
|
||||
@@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
|
||||
return
|
||||
}
|
||||
visited.add(s.sessionId)
|
||||
out.push({ ...s, depth })
|
||||
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
|
||||
const kids = children.get(s.sessionId)
|
||||
if (kids === undefined) return
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
|
||||
@@ -12,7 +12,12 @@ import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
|
||||
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
|
||||
import { flattenLineage } from './lineage.ts'
|
||||
// Type-only merge edge: the title domain's client-namespace outlet declares
|
||||
// the 'title' projection key this manager projects into list rows (and any
|
||||
// useProjection('title') consumer reads). Zero value imports by construction.
|
||||
import type {} from '@deepseek-ai/dsh-session-title/client'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import { Session } from './session.ts'
|
||||
|
||||
/**
|
||||
@@ -52,12 +57,6 @@ type SessionListMutation =
|
||||
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
|
||||
const PENDING_BUFFER_CAP = 32
|
||||
|
||||
/** Latest title control snapshot retained independently of list/instance arrival. */
|
||||
interface SessionTitleSnapshot {
|
||||
title: string
|
||||
eventSeq: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
export class SessionManager {
|
||||
@@ -67,7 +66,16 @@ export class SessionManager {
|
||||
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
|
||||
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
|
||||
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
|
||||
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
|
||||
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
|
||||
* replays of the same requested frame). Manager-owned rather than read off Session instances
|
||||
* because the sidebar must light up for sessions never instantiated. Cleared per connection
|
||||
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
|
||||
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
|
||||
/** Per-session projection value stores, retained independently of instance arrival (the
|
||||
* title-snapshot precedent, generalized): push frames land here whether or not the Session
|
||||
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
|
||||
* same store so history-baseline seeding and frames converge on one row set. */
|
||||
private readonly projectionStores = new Map<SessionId, ProjectionValueStore>()
|
||||
private summaries: SessionSummary[] = []
|
||||
private listState: 'idle' | 'loading' | 'error' = 'idle'
|
||||
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
|
||||
@@ -172,9 +180,23 @@ export class SessionManager {
|
||||
onEngaged: (engaged) => {
|
||||
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
|
||||
},
|
||||
projections: this.projectionStore(sessionId),
|
||||
})
|
||||
}
|
||||
|
||||
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
|
||||
private projectionStore(sessionId: SessionId): ProjectionValueStore {
|
||||
let store = this.projectionStores.get(sessionId)
|
||||
if (store === undefined) {
|
||||
store = new ProjectionValueStore()
|
||||
// List rows project off store keys (title); any-key changes re-enter
|
||||
// the manager's own batched rebuild channel.
|
||||
store.subscribeAny(() => { this.notifier.markDirty() })
|
||||
this.projectionStores.set(sessionId, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
@@ -204,6 +226,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
|
||||
@@ -329,23 +364,20 @@ export class SessionManager {
|
||||
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
|
||||
if (frame.type === 'session/title') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
|
||||
this.titleSnapshots.set(frame.sessionId, {
|
||||
title: frame.title,
|
||||
eventSeq: frame.eventSeq,
|
||||
updatedAt: frame.updatedAt,
|
||||
})
|
||||
if (frame.type === 'session/projection') {
|
||||
// Finished host-computed value: land it in the resident store whether or
|
||||
// not the Session is instantiated (list rows read the 'title' key). The
|
||||
// synchronous markDirty keeps the list snapshot same-tick fresh (the
|
||||
// store's own any-key channel is microtask-batched).
|
||||
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (frame.type === 'session/subscribed') {
|
||||
const current = this.titleSnapshots.get(frame.sessionId)
|
||||
if (current !== undefined && current.eventSeq > frame.lastSeq) {
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
// Rows past the host's durable baseline rode state a restart lost; drop
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
this.notifier.markDirty()
|
||||
// New mux-generation baseline: buffered session/queued frames belong to
|
||||
// the previous generation and the host is about to resend the live
|
||||
// snapshot — drop them, or every reconnect appends a duplicate batch
|
||||
@@ -360,6 +392,22 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
|
||||
// every session, instantiated or not; approvalId keys make replays idempotent.
|
||||
if (frame.type === 'approval/requested') {
|
||||
let ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
|
||||
if (!ids.has(frame.approvalId)) {
|
||||
ids.add(frame.approvalId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
} else if (frame.type === 'approval/resolved') {
|
||||
const ids = this.waitingApprovals.get(frame.sessionId)
|
||||
if (ids !== undefined && ids.delete(frame.approvalId)) {
|
||||
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
const session = this.sessions.get(frame.sessionId)
|
||||
if (session === undefined) {
|
||||
// Approval/question/queued frames never hit history: buffer for replay on
|
||||
@@ -404,7 +452,8 @@ export class SessionManager {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.titleSnapshots.delete(frame.sessionId)
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
return
|
||||
}
|
||||
case 'host/session-status': {
|
||||
@@ -421,6 +470,30 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The moment a connection generation dies (before any next-generation frame
|
||||
* can arrive — onConnected waits for the readiness handshake while replayed
|
||||
* frames flow from stream open, so clearing there would race the replay):
|
||||
* drop generation-scoped live state. Approvals resolved while disconnected
|
||||
* send no frame, so the stale bits and the buffered answerable frames must
|
||||
* not survive into the next generation — the mux-open replay re-adds every
|
||||
* still-pending question with its live rpcId.
|
||||
*/
|
||||
handleDisconnected(): void {
|
||||
if (this.waitingApprovals.size > 0) {
|
||||
this.waitingApprovals.clear()
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
|
||||
const kept = buffer.filter(item =>
|
||||
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
|
||||
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
|
||||
if (kept.length === buffer.length) continue
|
||||
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
|
||||
else this.pendingBuffers.set(sessionId, kept)
|
||||
}
|
||||
}
|
||||
|
||||
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
|
||||
handleConnected(): void {
|
||||
void this.refreshList()
|
||||
@@ -429,12 +502,14 @@ export class SessionManager {
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
const title = this.titleSnapshots.get(summary.sessionId)
|
||||
return title === undefined
|
||||
? summary
|
||||
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
// value; the bespoke session/title frame is retired).
|
||||
const title = this.projectionStores.get(summary.sessionId)?.get('title')
|
||||
return typeof title === 'string' && title !== ''
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
})
|
||||
const fresh = flattenLineage(merged)
|
||||
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
|
||||
const items = fresh.map((entry) => {
|
||||
const prev = this.entryCache.get(entry.sessionId)
|
||||
if (
|
||||
@@ -442,6 +517,7 @@ export class SessionManager {
|
||||
&& prev.blank === entry.blank
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.waitingApproval === entry.waitingApproval
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
return entry
|
||||
|
||||
183
packages/client/runtime/src/client/sessions/projection-store.ts
Normal file
183
packages/client/runtime/src/client/sessions/projection-store.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Generic per-session projection value store (session-projection RFC, push
|
||||
* model): the host is the only computation site; the client holds finished
|
||||
* whole values per key — `key → { value, seq }` — seeded by the history tail
|
||||
* page's projections block and updated by `session/projection` push frames,
|
||||
* under the single rule **higher seq wins**. No client-side domain folding
|
||||
* exists: a domain ships projection support with zero client code. Per-key
|
||||
* bare observable faces feed `useProjection` (web-react binds them).
|
||||
*/
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { ObservableSnapshot } from '../contract/store.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
|
||||
// The single projection type table, typed end to end (host unit, wire block,
|
||||
// client store, React hook) — the interface package's pure-type outlet
|
||||
// (`/types`, zero imports), never the package root: the root's dsh-agent →
|
||||
// dsh-session chain would drag the host `Context.sessions` merge into the
|
||||
// client program (one program must not hold both sides). No second
|
||||
// client-side "views" table (user ruling, RFC Alternatives).
|
||||
export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/**
|
||||
* The fifth framework hook seat (session-projection RFC): key-addressed
|
||||
* projection reader delivered through the standard kit. `undefined` uniformly
|
||||
* means capability absent — host unit unmounted, or no baseline/frame has
|
||||
* carried the key yet. The selector overload mirrors useSession (per-key uSES
|
||||
* binding; reference stability holds because a key's value reference changes
|
||||
* only when a frame or baseline lands).
|
||||
*/
|
||||
export type UseProjection = {
|
||||
<K extends Extract<keyof SessionProjectionMap, string>>(key: K): SessionProjectionMap[K] | undefined
|
||||
<K extends Extract<keyof SessionProjectionMap, string>, S>(
|
||||
key: K,
|
||||
selector: (value: SessionProjectionMap[K] | undefined) => S,
|
||||
eq?: (a: S, b: S) => boolean,
|
||||
): S
|
||||
}
|
||||
|
||||
/**
|
||||
* Tail-page projections baseline — structurally identical to the wire's
|
||||
* `SessionProjectionsBlock` (apiproxy api layer), restated here so the
|
||||
* React-free store depends only on the type table, not the wire package's
|
||||
* response vocabulary.
|
||||
*/
|
||||
export interface ProjectionsBaseline {
|
||||
/** The consistent-cut seq (equals the window tail seq by construction). */
|
||||
asOfSeq: number
|
||||
/** Whole current values by key; a registered key absent here means the capability is absent. */
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/** One key's row: the latest finished value and the seq it is consistent with. */
|
||||
interface Row {
|
||||
value: unknown
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Per-key notification channel: the bare face plus its batching notifier. */
|
||||
interface Channel {
|
||||
face: ObservableSnapshot<unknown>
|
||||
notifier: Notifier
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's projection values. Framework semantics, uniform across every
|
||||
* key: a baseline seeds rows at its cut, a push frame updates one row, and in
|
||||
* both paths a lower-or-equal seq loses — a replayed frame cannot regress a
|
||||
* value, a stale baseline cannot overwrite a newer frame. A key the store has
|
||||
* never seen reads `undefined` (capability absent). Faces are identity-stable
|
||||
* per key (create-on-demand, cached) so the React side binds each exactly
|
||||
* once; the store-level channel (`subscribeAny`) serves coarse consumers (the
|
||||
* manager's list projection reads the `title` key).
|
||||
*/
|
||||
export class ProjectionValueStore {
|
||||
private readonly rows = new Map<string, Row>()
|
||||
private readonly channels = new Map<string, Channel>()
|
||||
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
|
||||
private readonly anyNotifier = new Notifier(() => {})
|
||||
|
||||
/**
|
||||
* Key-addressed bare observable face (the useProjection resolution path).
|
||||
* Always defined — absence is an `undefined` snapshot, never a missing
|
||||
* face, so a component may subscribe before the key ever carries a value.
|
||||
* @param key - projection key.
|
||||
* @returns the identity-stable face for this key.
|
||||
*/
|
||||
faceOf(key: string): ObservableSnapshot<unknown> {
|
||||
return this.channel(key).face
|
||||
}
|
||||
|
||||
/**
|
||||
* Current whole value for a key (erased framework read; typed reads go
|
||||
* through `useProjection`'s map lookup).
|
||||
* @param key - projection key.
|
||||
* @returns the value, or undefined while the key is absent.
|
||||
*/
|
||||
get(key: string): unknown {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any-key changes (microtask-batched) — the manager's list
|
||||
* rebuild channel.
|
||||
* @param listener - change callback.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
subscribeAny(listener: () => void): () => void {
|
||||
return this.anyNotifier.subscribe(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one finished value (the `session/projection` push-frame path).
|
||||
* @param key - projection key.
|
||||
* @param value - whole value computed by the host unit.
|
||||
* @param seq - the unit's watermark at emission.
|
||||
*/
|
||||
apply(key: string, value: unknown, seq: number): void {
|
||||
const row = this.rows.get(key)
|
||||
if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop
|
||||
this.rows.set(key, { value, seq })
|
||||
this.changed(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed from a history tail page's projections block: every carried key
|
||||
* lands under the same seq rule as frames; a key the block omits is
|
||||
* capability-absent as of the cut — its row clears unless a newer frame
|
||||
* already superseded the cut (a stale baseline can neither overwrite nor
|
||||
* clear newer values).
|
||||
* @param baseline - the response's projections block.
|
||||
*/
|
||||
seed(baseline: ProjectionsBaseline): void {
|
||||
// Erased walk: the framework crosses the open key space; per-key typing
|
||||
// is re-established at the consumer (useProjection's map lookup).
|
||||
const values = baseline.values as Record<string, unknown>
|
||||
for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq)
|
||||
for (const [key, row] of this.rows) {
|
||||
if (Object.hasOwn(values, key)) continue
|
||||
if (row.seq > baseline.asOfSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`):
|
||||
* a row claiming knowledge beyond the host's own durable baseline rode
|
||||
* state a restart lost — under last-wins it would wrongly outrank the
|
||||
* host's recomputed (lower-seq) values forever. Durable replay and the next
|
||||
* baseline re-seed whatever truly survived (the title-snapshot precedent,
|
||||
* generalized).
|
||||
* @param lastSeq - the subscribed frame's durable baseline seq.
|
||||
*/
|
||||
truncate(lastSeq: number): void {
|
||||
for (const [key, row] of this.rows) {
|
||||
if (row.seq <= lastSeq) continue
|
||||
this.rows.delete(key)
|
||||
this.changed(key)
|
||||
}
|
||||
}
|
||||
|
||||
private changed(key: string): void {
|
||||
this.channels.get(key)?.notifier.markDirty()
|
||||
this.anyNotifier.markDirty()
|
||||
}
|
||||
|
||||
private channel(key: string): Channel {
|
||||
let channel = this.channels.get(key)
|
||||
if (channel === undefined) {
|
||||
// The notifier only batches (no snapshot cache to rebuild: faces read rows directly).
|
||||
const notifier = new Notifier(() => {})
|
||||
channel = {
|
||||
notifier,
|
||||
face: {
|
||||
getSnapshot: () => this.rows.get(key)?.value,
|
||||
subscribe: listener => notifier.subscribe(listener),
|
||||
},
|
||||
}
|
||||
this.channels.set(key, channel)
|
||||
}
|
||||
return channel
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Request-centric inspection read model. Ordinary generation and compaction
|
||||
// calls share one chronological projection; presentation-specific grouping
|
||||
// remains in the trajectory consumer.
|
||||
|
||||
import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
|
||||
export type {
|
||||
AssistantProvenanceView, AssistantRequestConfig,
|
||||
} from './conversation.ts'
|
||||
|
||||
/** Complete model-visible request header in force for an ordinary generation. */
|
||||
export interface ConversationPromptSnapshot {
|
||||
/** Provider/model and sampling configuration from the effective request header. */
|
||||
config: AssistantRequestConfig
|
||||
/** Rendered system prompt text; empty when the request had no system prompt. */
|
||||
system: string
|
||||
/** Complete tool catalog sent with the request, including tools that were never called. */
|
||||
tools: readonly ToolSchema[]
|
||||
}
|
||||
|
||||
/** System/tool change introduced while preparing one ordinary request. */
|
||||
export interface RequestPromptChange {
|
||||
/** Sequence of the request/header event that introduced this state. */
|
||||
seq: number
|
||||
/** Unix epoch ms from the request/header event. */
|
||||
time: number
|
||||
/** How the model-visible prompt differs from the previous recorded state. */
|
||||
kind: 'initial' | 'system' | 'tools' | 'system-and-tools'
|
||||
/** State immediately before this change; absent for the initial header. */
|
||||
previous?: ConversationPromptSnapshot
|
||||
}
|
||||
|
||||
/** One provider request reconstructed from durable request lifecycle events. */
|
||||
export interface RequestView {
|
||||
/** Request category; compaction is a purpose, not a separate projection. */
|
||||
purpose: 'assistant' | 'compaction'
|
||||
/** Sequence that opened the operation represented by this request. */
|
||||
startSeq: number
|
||||
turn: number
|
||||
/** Agent-loop step, or zero for a direct compaction request. */
|
||||
step: number
|
||||
startedAt: number
|
||||
completedAt: number | null
|
||||
status: 'running' | 'complete' | 'error'
|
||||
error?: string
|
||||
/** Effective ordinary request input, inherited until a later header changes it. */
|
||||
prompt?: ConversationPromptSnapshot
|
||||
/** Prompt change logged while preparing this request. */
|
||||
promptChange?: RequestPromptChange
|
||||
provenance?: AssistantProvenanceView
|
||||
requestConfig?: AssistantRequestConfig
|
||||
usage?: unknown
|
||||
/** Assistant message or compaction summary sequence produced by this request. */
|
||||
resultSeq?: number
|
||||
/** Compaction replacement message sequence, when one was committed. */
|
||||
replacementSeq?: number
|
||||
/** Safe compaction summary projection. */
|
||||
summary?: readonly ContentBlock[]
|
||||
/** Complete compaction provider output before the safe projection. */
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
/** Retry ordinal scheduled after a failed ordinary request. */
|
||||
retry?: number
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
/** Immutable request-centric projection derived from one history window. */
|
||||
export interface RequestInspectionSnapshot {
|
||||
requests: readonly RequestView[]
|
||||
callSchemas: ReadonlyMap<string, ToolSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the request-centric read model from one immutable history window.
|
||||
* Compaction participates as a request purpose rather than a parallel
|
||||
* top-level collection.
|
||||
* @param entries - Contiguous raw session history.
|
||||
* @returns Requests and call-time schemas derived from that history.
|
||||
*/
|
||||
export function inspectRequests(
|
||||
entries: readonly HistoryEntry[],
|
||||
): RequestInspectionSnapshot {
|
||||
const events = entries.map(entry => entry.event)
|
||||
return {
|
||||
requests: deriveRequests(events),
|
||||
callSchemas: deriveCallSchemas(events),
|
||||
}
|
||||
}
|
||||
|
||||
interface RetryEvent {
|
||||
type: 'llm/retry'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
turn: number
|
||||
step: number
|
||||
retry: number
|
||||
maxRetries: number
|
||||
delayMs: number
|
||||
failure: { message: string }
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionStartEvent {
|
||||
type: 'compact/start'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number }
|
||||
}
|
||||
|
||||
interface CompactionSummaryEvent {
|
||||
type: 'compact/summary'
|
||||
seq: number
|
||||
time: number
|
||||
data: {
|
||||
summary: readonly ContentBlock[]
|
||||
rawOutput?: readonly ContentBlock[]
|
||||
provider: string
|
||||
model: string
|
||||
maxTokens?: number
|
||||
usage?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionEndEvent {
|
||||
type: 'compact/end'
|
||||
seq: number
|
||||
time: number
|
||||
data: { turn: number; error?: string }
|
||||
}
|
||||
|
||||
function requestKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage {
|
||||
const previous = current as TokenUsage | undefined
|
||||
return {
|
||||
inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens,
|
||||
outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens,
|
||||
...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
cacheReadTokens:
|
||||
(previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0),
|
||||
}),
|
||||
...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
cacheWriteTokens:
|
||||
(previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0),
|
||||
}),
|
||||
...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined
|
||||
? {}
|
||||
: {
|
||||
reasoningTokens:
|
||||
(previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function deriveCallSchemas(
|
||||
events: readonly SessionEvent[],
|
||||
): ReadonlyMap<string, ToolSchema> {
|
||||
let active = new Map<string, ToolSchema>()
|
||||
const calls = new Map<string, ToolSchema>()
|
||||
const capture = (callId: string, name: string): void => {
|
||||
if (calls.has(callId)) return
|
||||
const schema = active.get(name)
|
||||
if (schema !== undefined) calls.set(callId, schema)
|
||||
}
|
||||
for (const event of events) {
|
||||
if (event.type === 'request/header') {
|
||||
const tools: unknown = event.data.header.tools
|
||||
active = new Map(
|
||||
Array.isArray(tools)
|
||||
? (tools as ToolSchema[]).map(schema => [schema.name, schema])
|
||||
: [],
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
capture(String(event.data.callId), event.data.name)
|
||||
continue
|
||||
}
|
||||
const type = event.type as string
|
||||
if (type === 'tool/code-dispatch-start' || type === 'tool/code-dispatch') {
|
||||
const data = event.data as unknown as { subCallId: string; name: string }
|
||||
capture(data.subCallId, data.name)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function promptChange(
|
||||
previous: ConversationPromptSnapshot | undefined,
|
||||
prompt: ConversationPromptSnapshot,
|
||||
event: SessionEvent<'request/header'>,
|
||||
): RequestPromptChange | undefined {
|
||||
const systemChanged = previous !== undefined && previous.system !== prompt.system
|
||||
const toolsChanged = previous !== undefined
|
||||
&& JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools)
|
||||
if (previous !== undefined && !systemChanged && !toolsChanged) return
|
||||
return {
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
kind: previous === undefined
|
||||
? 'initial'
|
||||
: systemChanged && toolsChanged
|
||||
? 'system-and-tools'
|
||||
: systemChanged
|
||||
? 'system'
|
||||
: 'tools',
|
||||
...(previous === undefined ? {} : { previous }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Project ordinary and compaction provider calls into one chronological request stream. */
|
||||
function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] {
|
||||
const requests: RequestView[] = []
|
||||
const ordinaryByStep = new Map<string, number>()
|
||||
let activeStep: string | undefined
|
||||
let activePrompt: ConversationPromptSnapshot | undefined
|
||||
let activeCompaction: number | undefined
|
||||
|
||||
const update = (index: number | undefined, change: Partial<RequestView>): void => {
|
||||
if (index === undefined) return
|
||||
const request = requests[index]
|
||||
if (request !== undefined) requests[index] = { ...request, ...change }
|
||||
}
|
||||
|
||||
for (const sourceEvent of events) {
|
||||
if (sourceEvent.type === 'step/start') {
|
||||
const { turn, step } = sourceEvent.data
|
||||
const key = requestKey(turn, step)
|
||||
ordinaryByStep.set(key, requests.length)
|
||||
requests.push({
|
||||
purpose: 'assistant',
|
||||
startSeq: sourceEvent.seq,
|
||||
turn,
|
||||
step,
|
||||
startedAt: sourceEvent.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
...(activePrompt === undefined
|
||||
? {}
|
||||
: { prompt: activePrompt, requestConfig: activePrompt.config }),
|
||||
})
|
||||
activeStep = key
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'request/header') {
|
||||
const tools: unknown = sourceEvent.data.header.tools
|
||||
const prompt: ConversationPromptSnapshot = {
|
||||
config: sourceEvent.data.header.config,
|
||||
system: sourceEvent.data.header.system ?? '',
|
||||
tools: Array.isArray(tools) ? tools as ToolSchema[] : [],
|
||||
}
|
||||
const change = promptChange(activePrompt, prompt, sourceEvent)
|
||||
activePrompt = prompt
|
||||
update(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), {
|
||||
prompt,
|
||||
requestConfig: prompt.config,
|
||||
...(change === undefined ? {} : { promptChange: change }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (
|
||||
sourceEvent.type === 'assistant/chunk'
|
||||
&& sourceEvent.data.chunk.type === 'usage'
|
||||
) {
|
||||
const index = ordinaryByStep.get(
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
usage: addTokenUsage(request?.usage, sourceEvent.data.chunk.usage),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'assistant/message') {
|
||||
const index = ordinaryByStep.get(
|
||||
requestKey(sourceEvent.data.turn, sourceEvent.data.step),
|
||||
)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
update(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'complete',
|
||||
resultSeq: sourceEvent.seq,
|
||||
provenance: {
|
||||
provider: sourceEvent.data.message.source.provider,
|
||||
model: sourceEvent.data.message.source.model,
|
||||
},
|
||||
...(request?.usage !== undefined || sourceEvent.data.usage === undefined
|
||||
? {}
|
||||
: { usage: sourceEvent.data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'step/end') {
|
||||
const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step)
|
||||
const index = ordinaryByStep.get(key)
|
||||
const request = index === undefined ? undefined : requests[index]
|
||||
if (request?.status === 'running') {
|
||||
update(index, {
|
||||
completedAt: sourceEvent.time,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
if (activeStep === key) activeStep = undefined
|
||||
continue
|
||||
}
|
||||
if ((sourceEvent.type as string) === 'llm/retry') {
|
||||
const event = sourceEvent as unknown as RetryEvent
|
||||
update(ordinaryByStep.get(requestKey(event.data.turn, event.data.step)), {
|
||||
status: 'error',
|
||||
error: event.data.failure.message,
|
||||
retry: event.data.retry,
|
||||
maxRetries: event.data.maxRetries,
|
||||
retryDelayMs: event.data.delayMs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (sourceEvent.type === 'turn/end' && sourceEvent.data.reason.kind === 'error') {
|
||||
const reason = sourceEvent.data.reason
|
||||
update(ordinaryByStep.get(requestKey(sourceEvent.data.turn, reason.step)), {
|
||||
status: 'error',
|
||||
error: 'failure' in reason ? reason.failure.message : reason.message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const type = sourceEvent.type as string
|
||||
if (type === 'compact/start') {
|
||||
const event = sourceEvent as unknown as CompactionStartEvent
|
||||
activeCompaction = requests.length
|
||||
requests.push({
|
||||
purpose: 'compaction',
|
||||
startSeq: event.seq,
|
||||
turn: event.data.turn,
|
||||
step: 0,
|
||||
startedAt: event.time,
|
||||
completedAt: null,
|
||||
status: 'running',
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (type === 'compact/summary' && activeCompaction !== undefined) {
|
||||
const event = sourceEvent as unknown as CompactionSummaryEvent
|
||||
update(activeCompaction, {
|
||||
resultSeq: event.seq,
|
||||
summary: event.data.summary,
|
||||
...(event.data.rawOutput === undefined ? {} : { rawOutput: event.data.rawOutput }),
|
||||
provenance: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
},
|
||||
requestConfig: {
|
||||
provider: event.data.provider,
|
||||
model: event.data.model,
|
||||
purpose: 'compaction',
|
||||
...(event.data.maxTokens === undefined ? {} : { maxTokens: event.data.maxTokens }),
|
||||
},
|
||||
...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (
|
||||
sourceEvent.type === 'user/message'
|
||||
&& activeCompaction !== undefined
|
||||
&& isCompactionSource(sourceEvent.data.source)
|
||||
) {
|
||||
update(activeCompaction, { replacementSeq: sourceEvent.seq })
|
||||
continue
|
||||
}
|
||||
if (type !== 'compact/end' || activeCompaction === undefined) continue
|
||||
const event = sourceEvent as unknown as CompactionEndEvent
|
||||
update(activeCompaction, {
|
||||
completedAt: event.time,
|
||||
status: event.data.error === undefined ? 'complete' : 'error',
|
||||
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
||||
})
|
||||
activeCompaction = undefined
|
||||
}
|
||||
|
||||
return requests.sort((left, right) => left.startSeq - right.startSeq)
|
||||
}
|
||||
|
||||
function isCompactionSource(source: unknown): boolean {
|
||||
return typeof source === 'object'
|
||||
&& source !== null
|
||||
&& 'kind' in source
|
||||
&& source.kind === 'plugin'
|
||||
&& 'plugin' in source
|
||||
&& source.plugin === 'compact'
|
||||
}
|
||||
@@ -27,9 +27,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, SessionSearchResultItem } 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. */
|
||||
@@ -42,6 +45,8 @@ export interface SessionSummary {
|
||||
cwd?: string
|
||||
parentId?: SessionId
|
||||
running: boolean
|
||||
/** An approval question is pending on this session (sidebar amber-dot state). */
|
||||
waitingApproval: boolean
|
||||
/**
|
||||
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
|
||||
* one targeting the same workspace. Filtering stays with the consumer: the
|
||||
@@ -84,7 +89,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
|
||||
}
|
||||
|
||||
@@ -124,6 +130,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
|
||||
}
|
||||
@@ -151,7 +159,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 {
|
||||
/**
|
||||
* The wire schema's own result bound, re-exposed for presentation plugins as
|
||||
* injected data. Not per-connection state: the `session.search` response
|
||||
@@ -163,6 +171,13 @@ export class SessionsService {
|
||||
readonly list: SnapshotStore<SessionListState>
|
||||
/** The object-layer instance cluster and frame dispatch entry. */
|
||||
private readonly manager: SessionManager
|
||||
/**
|
||||
* Atomic current-session provide projection: selection changes and
|
||||
* provider-roster changes publish through this one source (the renderer
|
||||
* host's `sessions.provide` feed), so a roster change under a stable
|
||||
* current id republishes the bundle instead of stranding mounted entries.
|
||||
*/
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
@@ -175,10 +190,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
|
||||
/** 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
|
||||
@@ -213,14 +226,20 @@ export class SessionsService {
|
||||
// dedicated code path. Safe to run synchronously inside the store notify:
|
||||
// the follower writes no list state — session.open()'s synchronous prefix
|
||||
// touches only session-side state and its own microtask-batched notifier.
|
||||
this.list.subscribe(() => { this.followCurrent() })
|
||||
// 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 } }),
|
||||
// The current-provide projection follows the same current writes.
|
||||
this.list.subscribe(() => {
|
||||
this.followCurrent()
|
||||
this.provideChannel.publishCurrent()
|
||||
})
|
||||
this.maybeInfo = this.materializeMaybeProvideInfo()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -235,74 +254,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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,6 +323,11 @@ export class SessionsService {
|
||||
this.manager.handleConnected()
|
||||
}
|
||||
|
||||
/** Drop generation-scoped live interaction state the moment a connection generation dies. */
|
||||
handleDisconnected(): void {
|
||||
this.manager.handleDisconnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host. Resolution guarantee: by the time the
|
||||
* promise resolves, the created session is in the list store and
|
||||
@@ -414,9 +374,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
|
||||
@@ -433,26 +393,22 @@ export class SessionsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the render-layer standard-props bundle (SessionProvider's feed
|
||||
* through the renderer host; ctx never enters the render layer). Pure
|
||||
* resolution — render-safe: SessionProvider calls this during render, so no
|
||||
* staging, no window side effects (StrictMode double-invokes and concurrent
|
||||
* discarded passes must stay free).
|
||||
* @param id - session id.
|
||||
* @returns the provide info, or undefined for a session neither listed nor already scoped.
|
||||
* Resolve one session's render-layer standard-props bundle (ctx never
|
||||
* enters the render layer; the renderer subscribes to
|
||||
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
|
||||
* no staging, no window side effects (StrictMode double-invokes and
|
||||
* concurrent discarded passes must stay free).
|
||||
*/
|
||||
provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
private provideInfo(id: string): SessionProvideInfo | undefined {
|
||||
return this.resolve(id as SessionId)?.provideInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current-session-optional standard kit. Unknown or absent ids
|
||||
* return the static no-session projection rather than removing hook props.
|
||||
* @param id - current session id, when selected.
|
||||
* @returns a definite or no-session provide bundle.
|
||||
*/
|
||||
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
|
||||
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.provideChannel.maybeInfo
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,7 +432,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,8 +475,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
|
||||
@@ -542,6 +499,7 @@ export class SessionsService {
|
||||
id: entry.sessionId,
|
||||
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
|
||||
running: entry.running,
|
||||
waitingApproval: entry.waitingApproval,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
@@ -587,7 +545,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)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
|
||||
SessionId, ToolEventView,
|
||||
@@ -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,
|
||||
@@ -20,6 +20,8 @@ import { PendingWait } from './pending.ts'
|
||||
import { FoldAdapter } from './fold-adapter.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
@@ -35,6 +37,12 @@ export interface SessionOptions {
|
||||
* (hidden, still reusable by connectWorkspace).
|
||||
*/
|
||||
onEngaged?(session: Session): void
|
||||
/**
|
||||
* Manager-owned projection value store to adopt (frames route through the
|
||||
* manager and values outlive instantiation); omitted, the Session owns a
|
||||
* private store (bare object-layer construction).
|
||||
*/
|
||||
projections?: ProjectionValueStore
|
||||
}
|
||||
|
||||
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
|
||||
@@ -59,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).
|
||||
@@ -99,9 +109,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
|
||||
private frozenRev = 0
|
||||
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
|
||||
/** Current whole-list todo/write projection: each tail history response replaces it (an omitted
|
||||
* field is the authoritative empty list) and every live write overwrites it. */
|
||||
private todos: readonly TodoItem[] = []
|
||||
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
|
||||
* copy-on-write the per-parent array so published snapshot references never mutate. */
|
||||
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
|
||||
@@ -126,6 +133,19 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
|
||||
private subscribedLastSeq: number | null = null
|
||||
|
||||
/**
|
||||
* Per-session projection value store (session-projection RFC, push model):
|
||||
* finished whole values computed on the host, seeded by the tail page's
|
||||
* projections block and updated by `session/projection` frames under the
|
||||
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
|
||||
* (the useProjection resolution face); the conversation snapshot never
|
||||
* carries projection values, and no client-side domain folding exists.
|
||||
* Manager-owned when constructed through SessionManager (frames route and
|
||||
* the store outlives instantiation, the title-snapshot precedent); a bare
|
||||
* construction gets a private store.
|
||||
*/
|
||||
readonly projections: ProjectionValueStore
|
||||
|
||||
private snapshotCache: ConversationSnapshot
|
||||
private readonly notifier = new Notifier(() => {
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
@@ -149,6 +169,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private readonly api: IApiClient,
|
||||
private readonly options: SessionOptions = {},
|
||||
) {
|
||||
this.projections = options.projections ?? new ProjectionValueStore()
|
||||
this.snapshotCache = this.buildSnapshot()
|
||||
}
|
||||
|
||||
@@ -197,12 +218,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)
|
||||
@@ -229,6 +252,40 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename: contract session.rename 1:1. On success settle the 'title'
|
||||
* projection cell from the response's `{title, seq}` under the store's
|
||||
* higher-seq-wins rule (the push frame arriving later is a no-op replay),
|
||||
* so the list row and any useProjection('title') reader update without
|
||||
* waiting for the mux frame.
|
||||
* @param title - raw title text (the host normalizes acceptance).
|
||||
* @returns the rename result (normalized accepted title + title event seq).
|
||||
*/
|
||||
async rename(title: string): Promise<RpcResult<{ title: string; seq: number }>> {
|
||||
try {
|
||||
const { result } = await this.api.sessions.rename({ sessionId: this.sessionId, title })
|
||||
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
|
||||
return result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one slash-command line against this session's agent — pure
|
||||
* admission semantics (the host executor durably logs the lifecycle;
|
||||
* outcomes render as flow nodes, never as a response echo).
|
||||
* @param line - the full command line, leading slash included.
|
||||
* @returns the admission result, or the error branch on transport failure.
|
||||
*/
|
||||
async command(line: string): Promise<RpcResult<{ matched: boolean }>> {
|
||||
try {
|
||||
return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result
|
||||
} catch (error) {
|
||||
return transportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
|
||||
open(): Promise<void> {
|
||||
if (this.openState === 'open') return Promise.resolve()
|
||||
@@ -341,13 +398,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return
|
||||
}
|
||||
case 'session/queued': {
|
||||
const message = frame.message
|
||||
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
|
||||
// provisional-echo reconciliation key); otherwise the frame envelope id.
|
||||
const key = 'rpcId' in frame.source ? String(frame.source.rpcId) : `f:${rpcId}`
|
||||
const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}`
|
||||
this.queued.push({
|
||||
row: { key, preview: queuePreviewOf(frame.content) },
|
||||
row: { key, preview: queuePreviewOf(message.content) },
|
||||
steering: frame.steering,
|
||||
sourceJson: JSON.stringify(frame.source),
|
||||
sourceJson: JSON.stringify(message.source),
|
||||
})
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
@@ -482,13 +540,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.openError = result.error
|
||||
return
|
||||
}
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
|
||||
const tailSeq = this.windowTailSeq()
|
||||
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
|
||||
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
|
||||
if (generation !== this.openGeneration) return
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
this.openState = 'open'
|
||||
} catch (error) {
|
||||
@@ -505,22 +563,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
|
||||
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
|
||||
* (doOpen flips it after install), so recursing would push every buffered event straight
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void {
|
||||
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
|
||||
* A carried projections block seeds the value store (higher seq wins, so a stale
|
||||
* baseline cannot overwrite a newer push frame); the window events themselves are
|
||||
* never folded — the host is the only computation site. */
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.events = entries.map(e => e.event)
|
||||
this.views = entries.map(e => e.view)
|
||||
this.baseSeq = this.events[0]?.seq ?? 0
|
||||
this.hasMore = hasMore
|
||||
// Session-level projection from the tail page (full-log latest todo/write,
|
||||
// independent of the window); an in-window write below re-derives the same
|
||||
// value, and later live events keep overwriting it. Every caller here is a
|
||||
// tail request (no beforeSeq), which the host answers with the projection
|
||||
// or omits it only when the full log holds no todo/write — so an absent
|
||||
// field is the authoritative empty list, not a missing carrier. Assigning
|
||||
// it clears a plan the log never kept (a write lost to a host crash).
|
||||
this.todos = todos ?? []
|
||||
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
|
||||
this.rebuildDerivedFromWindow()
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
const buffered = this.liveBuffer
|
||||
this.liveBuffer = []
|
||||
for (const item of buffered) this.appendLive(item.event, item.view)
|
||||
@@ -569,7 +623,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
|
||||
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
|
||||
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.todos)
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
@@ -588,7 +642,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (event.data.trigger.kind !== 'message') return
|
||||
index = this.queued.findIndex(entry => !entry.steering)
|
||||
} else if (event.type === 'steering/message') {
|
||||
const source = JSON.stringify(event.data.source)
|
||||
const source = JSON.stringify(event.data.message.source)
|
||||
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
|
||||
} else {
|
||||
return
|
||||
@@ -686,11 +740,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
return
|
||||
}
|
||||
case 'tool/result': {
|
||||
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
|
||||
return
|
||||
}
|
||||
case 'todo/write': {
|
||||
this.todos = event.data.todos
|
||||
if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++
|
||||
return
|
||||
}
|
||||
case 'turn/end': {
|
||||
@@ -737,10 +787,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
|
||||
/** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
|
||||
* paging/stitching consistent, and makes the live freeze and the history replay converge on the
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text).
|
||||
* todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log
|
||||
* projection, not derivable from an arbitrary window). The window always extends to the log
|
||||
* tail, so an in-window todo/write can only overwrite it with the same latest value. */
|
||||
* same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
|
||||
private rebuildDerivedFromWindow(): void {
|
||||
this.partial = null
|
||||
this.openCalls.clear()
|
||||
@@ -799,7 +846,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
queue: this.queueCache.value,
|
||||
running: this.running,
|
||||
composerPhase: derivePhase(
|
||||
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
// Command lifecycle nodes are not conversation: running /permission
|
||||
// or /plan on a fresh session keeps the hero (the client mirror of
|
||||
// the host's no-turn sessionBlank predicate).
|
||||
nodes.some(node => node.kind !== 'command') || partial !== null || this.running || this.pendingCache.value.length > 0,
|
||||
this.promptAttempted,
|
||||
),
|
||||
removed: this.removed,
|
||||
@@ -810,7 +860,6 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
promptError: this.promptError,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
todos: this.todos,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -821,7 +870,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* object: `hasContent` only grows within a window and `promptAttempted` is
|
||||
* sticky, so blank → engaging → active never steps back; a failed first
|
||||
* prompt stays engaging (retry semantics — see ComposerPhase).
|
||||
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
|
||||
* @param hasContent - any conversation material exists (non-command nodes,
|
||||
* partial, running turn, pending waits; command lifecycle rows alone keep
|
||||
* the session blank).
|
||||
* @param promptAttempted - a prompt was initiated on this session object.
|
||||
* @returns the derived phase.
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* with the last holding entry, session instances cleared (with persisted
|
||||
* state) on scope death.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
|
||||
/* oxlint-disable typescript/no-redundant-type-constituents --
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
|
||||
* holds this package's 'root' row in this compilation unit, but consumers
|
||||
* merge keys in; the rule fires on the narrow-map view, not on real
|
||||
@@ -246,13 +246,6 @@ export class SlotsService extends Service {
|
||||
if (workspaces === undefined) {
|
||||
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
|
||||
}
|
||||
// Identity-stable view: current rides the list snapshot (arbitrated), but
|
||||
// the provider consumes it as its own observable; one cached object keeps
|
||||
// the renderer's per-source hook cache stable.
|
||||
const current = {
|
||||
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
|
||||
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
|
||||
}
|
||||
this._host = {
|
||||
subscribe: (key, fn) => this._core.subscribe(key, fn),
|
||||
getVersion: key => this._core.getVersion(key),
|
||||
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
|
||||
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
|
||||
sessions: {
|
||||
list: sessions.list,
|
||||
current,
|
||||
provideInfo: id => sessions.provideInfo(id),
|
||||
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
|
||||
provideInfo: sessions.currentProvideInfo,
|
||||
},
|
||||
workspaces: { list: workspaces.list },
|
||||
}
|
||||
@@ -319,6 +310,6 @@ export class SlotsService extends Service {
|
||||
// The core's overloads proved the shares; the implementation works on
|
||||
// the erased view (same pattern as the core's own implementation arm).
|
||||
const options = rawOptions as ErasedRegisterOptions
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return this.ctx.effect(() => this['_register'](options, component), 'slots.register()')
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
|
||||
DirectoryListing, IApiClient, RpcError,
|
||||
SessionId, WorkspaceId, WorkspaceView,
|
||||
} 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. */
|
||||
@@ -29,8 +31,16 @@ export class WorkspaceCreateError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Structured browse failure so the directory browser can branch on Host business codes. */
|
||||
export class DirectoryBrowseError extends Error {
|
||||
constructor(readonly rpcError: RpcError) {
|
||||
super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
|
||||
this.name = 'DirectoryBrowseError'
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 +53,9 @@ export class WorkspacesService {
|
||||
/**
|
||||
* @param ctx - client root context.
|
||||
* @param api - shared wire client.
|
||||
* @param sessions - lower-level Session service used for recency and blank-session reuse.
|
||||
* @param sessions - cross-domain sessions face used for recency and blank-session reuse.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) {
|
||||
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
|
||||
this.manager = new WorkspaceManager(api)
|
||||
this.list = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'pending', error: null,
|
||||
@@ -171,7 +181,7 @@ export class WorkspacesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Host's native directory picker.
|
||||
* Open the Host's native directory picker (the `native` capability).
|
||||
* @returns the selected path, or null when the user cancelled.
|
||||
*/
|
||||
async pickDirectory(): Promise<string | null> {
|
||||
@@ -182,6 +192,41 @@ export class WorkspacesService {
|
||||
return response.result.value.path
|
||||
}
|
||||
|
||||
/**
|
||||
* List one directory level through the Host's `browse` capability.
|
||||
* @param path - absolute directory to list; absent lists the Host home directory.
|
||||
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
|
||||
* @returns the level's listing with breadcrumb ancestry.
|
||||
*/
|
||||
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
|
||||
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
|
||||
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one child directory through the Host's `browse` capability.
|
||||
* @param path - absolute existing parent directory.
|
||||
* @param name - single non-blank path segment.
|
||||
* @returns the created directory's absolute path.
|
||||
*/
|
||||
async createDirectory(path: string, name: string): Promise<string> {
|
||||
const response = await this.api.host.createDirectory({ path, name })
|
||||
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
|
||||
return response.result.value.path
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the Host operating system's default application.
|
||||
* @param path - absolute or host-resolvable path.
|
||||
*/
|
||||
async openPath(path: string): Promise<void> {
|
||||
const response = await this.api.host.openPath({ path })
|
||||
if (!response.result.ok) {
|
||||
throw new Error(`path open failed: ${response.result.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
@@ -260,7 +305,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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
|
||||
/* oxlint-disable typescript/no-redundant-type-constituents --
|
||||
* `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty
|
||||
* in this compilation unit (intersection reads `never`) but consumers merge
|
||||
* keys in; the rule fires on the empty-map view, not on real redundancy. */
|
||||
|
||||
Reference in New Issue
Block a user